Compare commits

..
2 Commits
29 changed files with 3171 additions and 54 deletions
+49 -11
View File
@@ -61,6 +61,7 @@ def retry_agent_job(repository: object, job_id: UUID) -> AgentJobResponse:
agent_profile=parent.agent_profile,
status=AgentJobStatus.QUEUED,
input_files=[file_ref.model_dump(mode="json") for file_ref in parent.input_files],
payload=parent.payload,
queued_at=_now(),
)
return AgentJobResponse(job=retry)
@@ -88,6 +89,7 @@ def complete_agent_job(
duration_ms: int | None,
output: dict[str, Any],
) -> AgentJobResponse:
existing_job = repository.agent_jobs.get(job_id)
try:
validated_output = AgentJobOutput.model_validate(output)
except ValidationError as error:
@@ -96,6 +98,7 @@ def complete_agent_job(
status=AgentJobStatus.FAILED,
workspace_path=workspace_path,
output_files=[],
payload=existing_job.payload,
error_category=AgentJobErrorCategory.FAILED_SCHEMA_VALIDATION,
error_message=str(error),
stdout=stdout,
@@ -106,23 +109,40 @@ def complete_agent_job(
)
return AgentJobResponse(job=job)
merged_payload = _merge_payload(
base_payload=existing_job.payload,
new_payload=validated_output.payload,
)
status = _normalize_completion_status(
status=validated_output.status,
exit_code=exit_code,
error_category=validated_output.error_category,
)
error_category = _normalize_error_category(
initial=validated_output.error_category,
status=validated_output.status,
exit_code=exit_code,
)
error_message = validated_output.error_message
unsupported_claims = _extract_unsupported_claims(merged_payload)
if existing_job.job_type == AgentJobType.SECTION_SCAFFOLD and unsupported_claims:
status = AgentJobStatus.FAILED
error_category = AgentJobErrorCategory.UNSUPPORTED_CLAIMS_FOUND
if not error_message:
error_message = (
f"Unsupported claims introduced during scaffolding: {len(unsupported_claims)}"
)
job = repository.agent_jobs.complete(
job_id=job_id,
status=_normalize_completion_status(
status=validated_output.status,
exit_code=exit_code,
error_category=validated_output.error_category,
),
status=status,
workspace_path=workspace_path,
output_files=[
file_ref.model_dump(mode="json") for file_ref in validated_output.output_files
],
error_category=_normalize_error_category(
initial=validated_output.error_category,
status=validated_output.status,
exit_code=exit_code,
),
error_message=validated_output.error_message,
payload=merged_payload,
error_category=error_category,
error_message=error_message,
stdout=stdout,
stderr=stderr,
exit_code=exit_code,
@@ -163,3 +183,21 @@ def _normalize_error_category(
if status == AgentJobStatus.SUCCEEDED and exit_code not in (0, None):
return AgentJobErrorCategory.CLI_EXIT_CODE_FAILURE
return None
def _merge_payload(
*,
base_payload: dict[str, Any],
new_payload: dict[str, Any],
) -> dict[str, Any]:
merged = dict(base_payload)
for key, value in new_payload.items():
merged[key] = value
return merged
def _extract_unsupported_claims(payload: dict[str, Any]) -> list[Any]:
claims = payload.get("unsupported_claims")
if isinstance(claims, list):
return claims
return []
+4
View File
@@ -60,17 +60,21 @@ def get_article_detail(
workflow_events = repository.articles.list_workflow_events(article_id)
boundary_questions = repository.boundary_questions.list_for_article(article_id)
plans = repository.article_plans.list_for_article(article_id)
draft = repository.article_drafts.latest_for_article(article_id)
research_manifests = repository.research_manifests.list_for_article(article_id)
evidence = repository.evidence_items.list_for_article(article_id)
claims = repository.claims.list_for_article(article_id)
agent_jobs = repository.agent_jobs.list_for_article(article_id)
return ArticleDetailResponse(
article=article,
target_site=target_site,
workflow_events=workflow_events,
boundary_questions=boundary_questions,
plan=plans[-1] if plans else None,
draft=draft,
evidence=evidence,
claims=claims,
agent_jobs=agent_jobs,
research_manifests=research_manifests,
)
@@ -106,6 +106,7 @@ def generate_boundary_questions(
status=AgentJobStatus.SUCCEEDED,
workspace_path=None,
output_files=[{"path": "outputs/boundary-questions.json"}],
payload={},
error_category=None,
error_message=None,
stdout="fake boundary question fixture generated\n",
+451
View File
@@ -0,0 +1,451 @@
from __future__ import annotations
import re
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from src.domain.contracts import (
AgentJobStatus,
AgentJobSummary,
AgentJobType,
ArticleWorkflowStatus,
DraftAssembleResponse,
DraftFaqItem,
DraftListResponse,
DraftResponse,
DraftUpdateRequest,
PlanReviewStatus,
)
def assemble_draft(repository: object, *, article_id: UUID) -> DraftAssembleResponse:
article = repository.articles.get(article_id)
approved_plan = _approved_plan(repository, article_id=article_id)
if approved_plan is None or not approved_plan.sections:
raise PermissionError("Approved plan with sections is required before draft assembly")
section_jobs = repository.agent_jobs.list_for_article(article_id)
successful_jobs_by_section = _successful_jobs_by_section(section_jobs)
missing_section_headings = [
section.heading
for section in approved_plan.sections
if str(section.id) not in successful_jobs_by_section
]
if missing_section_headings:
raise ValueError(
"Missing successful section scaffolds for sections: "
+ ", ".join(missing_section_headings)
)
title = (
approved_plan.recommended_title
or article.working_title
or article.primary_keyword
or "Untitled draft"
)
meta_title = title
meta_description = _trim_description(article.brief_description or title)
section_blocks: list[str] = []
visual_placeholders: list[str] = []
evidence_references: list[str] = []
unsupported_warnings = _collect_job_warnings(section_jobs)
for section in approved_plan.sections:
section_job = successful_jobs_by_section[str(section.id)]
payload = section_job.payload
section_blocks.append(_section_markdown(section.heading, payload))
visual_placeholders.extend(
_visual_placeholders_for_section(
section_id=section.id,
heading=section.heading,
payload=payload,
)
)
evidence_references.extend(_string_list(payload.get("used_evidence_ids")))
deduped_evidence_references = _dedupe_strings(evidence_references)
deduped_unsupported_warnings = _dedupe_strings(unsupported_warnings)
deduped_visual_placeholders = _dedupe_strings(visual_placeholders)
faq_items = _build_faq_items(
claims_to_prove=approved_plan.claims_to_prove,
keyword=article.primary_keyword,
)
draft_body = _assemble_body_markdown(
title=title,
meta_title=meta_title,
meta_description=meta_description,
section_blocks=section_blocks,
evidence_references=deduped_evidence_references,
visual_placeholders=deduped_visual_placeholders,
faq_items=faq_items,
unsupported_warnings=deduped_unsupported_warnings,
)
now = _now()
next_version = repository.article_drafts.latest_version(article_id) + 1
draft = repository.article_drafts.create_version(
article_id=article_id,
version=next_version,
title=title,
slug=_slugify(title),
meta_title=meta_title,
meta_description=meta_description,
body_object_key=f"articles/{article_id}/drafts/v{next_version}.md",
body_markdown=draft_body,
faq_items=[item.model_dump(mode="json") for item in faq_items],
visual_placeholders=deduped_visual_placeholders,
evidence_references=[UUID(item_id) for item_id in deduped_evidence_references],
unsupported_claim_warnings=deduped_unsupported_warnings,
based_on_draft_id=None,
status=ArticleWorkflowStatus.DRAFT_ASSEMBLED,
created_at=now,
updated_at=now,
)
repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.DRAFT_ASSEMBLED,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="DRAFT_ASSEMBLED",
from_status=article.status,
to_status=ArticleWorkflowStatus.DRAFT_ASSEMBLED,
actor_user_id=None,
payload={
"draft_id": str(draft.id),
"version": draft.version,
"section_count": len(approved_plan.sections),
},
created_at=now,
)
return DraftAssembleResponse(draft=draft)
def list_drafts(repository: object, *, article_id: UUID) -> DraftListResponse:
repository.articles.get(article_id)
return DraftListResponse(drafts=repository.article_drafts.list_for_article(article_id))
def get_draft(repository: object, *, article_id: UUID, draft_id: UUID) -> DraftResponse:
repository.articles.get(article_id)
draft = repository.article_drafts.get(article_id=article_id, draft_id=draft_id)
return DraftResponse(draft=draft)
def update_draft(
repository: object,
*,
article_id: UUID,
draft_id: UUID,
request: DraftUpdateRequest,
) -> DraftResponse:
repository.articles.get(article_id)
base = repository.article_drafts.get(article_id=article_id, draft_id=draft_id)
if _request_is_empty(request):
raise ValueError("Draft update payload is empty")
next_version = repository.article_drafts.latest_version(article_id) + 1
now = _now()
title = request.title if request.title is not None else base.title
draft = repository.article_drafts.create_version(
article_id=article_id,
version=next_version,
title=title,
slug=_slugify(title),
meta_title=(
request.meta_title if request.meta_title is not None else base.meta_title
),
meta_description=(
request.meta_description
if request.meta_description is not None
else base.meta_description
),
body_object_key=f"articles/{article_id}/drafts/v{next_version}.md",
body_markdown=(
request.body_markdown
if request.body_markdown is not None
else base.body_markdown
),
faq_items=[
item.model_dump(mode="json")
for item in (
request.faq_items
if request.faq_items is not None
else base.faq_items
)
],
visual_placeholders=(
request.visual_placeholders
if request.visual_placeholders is not None
else base.visual_placeholders
),
evidence_references=(
request.evidence_references
if request.evidence_references is not None
else base.evidence_references
),
unsupported_claim_warnings=(
request.unsupported_claim_warnings
if request.unsupported_claim_warnings is not None
else base.unsupported_claim_warnings
),
based_on_draft_id=base.id,
status=base.status,
created_at=now,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="DRAFT_VERSION_CREATED",
from_status=ArticleWorkflowStatus.DRAFT_ASSEMBLED,
to_status=ArticleWorkflowStatus.DRAFT_ASSEMBLED,
actor_user_id=None,
payload={
"draft_id": str(draft.id),
"version": draft.version,
"based_on_draft_id": str(base.id),
},
created_at=now,
)
return DraftResponse(draft=draft)
def _approved_plan(repository: object, *, article_id: UUID) -> object | None:
plans = repository.article_plans.list_for_article(article_id)
approved_plans = [
plan for plan in plans if plan.status == PlanReviewStatus.APPROVED
]
if not approved_plans:
return None
approved_plans.sort(key=lambda plan: plan.version)
return approved_plans[-1]
def _successful_jobs_by_section(
jobs: list[AgentJobSummary],
) -> dict[str, AgentJobSummary]:
sections: dict[str, AgentJobSummary] = {}
for job in jobs:
if job.job_type != AgentJobType.SECTION_SCAFFOLD:
continue
if job.status != AgentJobStatus.SUCCEEDED:
continue
section_id = _string_value(job.payload.get("section_id"))
if not section_id:
continue
existing = sections.get(section_id)
if existing is None or _job_sort_key(job) > _job_sort_key(existing):
sections[section_id] = job
return sections
def _collect_job_warnings(jobs: list[AgentJobSummary]) -> list[str]:
warnings: list[str] = []
for job in jobs:
if job.job_type != AgentJobType.SECTION_SCAFFOLD:
continue
heading = _string_value(job.payload.get("heading")) or _string_value(
job.payload.get("artifact_label")
)
warnings.extend(
_unsupported_claim_warnings(
heading=heading or "section",
raw_claims=job.payload.get("unsupported_claims"),
)
)
return warnings
def _job_sort_key(job: AgentJobSummary) -> tuple[int, str]:
queued_at = job.queued_at.isoformat() if job.queued_at else ""
return (job.attempt, queued_at)
def _section_markdown(heading: str, payload: dict[str, Any]) -> str:
section_markdown = _string_value(payload.get("draft_markdown")).strip()
if not section_markdown:
return f"## {heading}\n\nDraft content is missing for this section."
if section_markdown.lstrip().startswith("#"):
return section_markdown
return f"## {heading}\n\n{section_markdown}"
def _visual_placeholders_for_section(
*,
section_id: UUID,
heading: str,
payload: dict[str, Any],
) -> list[str]:
suggested_visuals = payload.get("suggested_visuals")
placeholders: list[str] = []
if isinstance(suggested_visuals, list):
for index, visual in enumerate(suggested_visuals, start=1):
label = _visual_label(visual, heading=heading, fallback_index=index)
placeholders.append(
f"![{label}](placeholder://{section_id}/{index})"
)
if placeholders:
return placeholders
return [f"![Visual placeholder: {heading}](placeholder://{section_id}/1)"]
def _visual_label(value: Any, *, heading: str, fallback_index: int) -> str:
if isinstance(value, str) and value.strip():
return value.strip()
if isinstance(value, dict):
for key in ("title", "caption", "prompt", "label"):
key_value = value.get(key)
if isinstance(key_value, str) and key_value.strip():
return key_value.strip()
return f"{heading} visual {fallback_index}"
def _unsupported_claim_warnings(*, heading: str, raw_claims: Any) -> list[str]:
if not isinstance(raw_claims, list):
return []
warnings: list[str] = []
for claim in raw_claims:
if isinstance(claim, str) and claim.strip():
warnings.append(f"{heading}: {claim.strip()}")
continue
if isinstance(claim, dict):
claim_text = claim.get("claim_text")
if isinstance(claim_text, str) and claim_text.strip():
warnings.append(f"{heading}: {claim_text.strip()}")
return warnings
def _build_faq_items(
*,
claims_to_prove: list[str],
keyword: str | None,
) -> list[DraftFaqItem]:
faq_items: list[DraftFaqItem] = []
for claim in claims_to_prove[:3]:
text = claim.strip()
if not text:
continue
faq_items.append(
DraftFaqItem(
question=f"What evidence supports: {text}?",
answer="Evidence references are listed in the section and summary blocks.",
)
)
if faq_items:
return faq_items
base_keyword = (keyword or "this topic").strip()
return [
DraftFaqItem(
question=f"What should readers know about {base_keyword}?",
answer="The draft sections provide context, implementation details, and references.",
)
]
def _assemble_body_markdown(
*,
title: str,
meta_title: str,
meta_description: str,
section_blocks: list[str],
evidence_references: list[str],
visual_placeholders: list[str],
faq_items: list[DraftFaqItem],
unsupported_warnings: list[str],
) -> str:
lines = [
f"# {title}",
"",
f"> Meta title: {meta_title}",
f"> Meta description: {meta_description}",
"",
]
lines.extend("\n\n".join(section_blocks).splitlines())
lines.extend(["", "## Evidence References", ""])
if evidence_references:
for evidence_id in evidence_references:
lines.append(f"- `{evidence_id}`")
else:
lines.append("- No evidence references recorded.")
lines.extend(["", "## Visual Placeholders", ""])
for placeholder in visual_placeholders:
lines.append(placeholder)
lines.extend(["", "## FAQ", ""])
for item in faq_items:
lines.append(f"### {item.question}")
lines.append("")
lines.append(item.answer)
lines.append("")
if unsupported_warnings:
lines.extend(["## Unsupported Claim Warnings", ""])
for warning in unsupported_warnings:
lines.append(f"- {warning}")
lines.append("")
return "\n".join(lines).strip() + "\n"
def _trim_description(value: str) -> str:
normalized = " ".join(value.split())
if len(normalized) <= 155:
return normalized
return normalized[:152].rstrip() + "..."
def _slugify(value: str) -> str:
normalized = value.strip().lower()
normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
normalized = normalized.strip("-")
if normalized:
return normalized
return "draft"
def _string_list(value: Any) -> list[str]:
if not isinstance(value, list):
return []
return [item for item in value if isinstance(item, str) and item]
def _string_value(value: Any) -> str:
if isinstance(value, str):
return value
return ""
def _dedupe_strings(values: list[str]) -> list[str]:
deduped: list[str] = []
seen: set[str] = set()
for value in values:
if value in seen:
continue
seen.add(value)
deduped.append(value)
return deduped
def _request_is_empty(request: DraftUpdateRequest) -> bool:
return all(
value is None
for value in (
request.title,
request.meta_title,
request.meta_description,
request.body_markdown,
request.faq_items,
request.visual_placeholders,
request.evidence_references,
request.unsupported_claim_warnings,
)
)
def _now() -> datetime:
return datetime.now(UTC)
+103 -12
View File
@@ -15,6 +15,7 @@ from src.domain.contracts import (
EvidenceResponse,
EvidenceCreateRequest,
EvidenceUpdateRequest,
PlanReviewStatus,
)
@@ -32,19 +33,40 @@ def get_evidence_matrix(repository: object, *, article_id: UUID) -> EvidenceMatr
claims = repository.claims.list_for_article(article_id)
reasons = _insufficient_reasons(claims)
if reasons and article.status != ArticleWorkflowStatus.PLAN_REVISION_REQUIRED:
from_status = article.status
now = _now()
article = repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
updated_at=_now(),
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="INSUFFICIENT_EVIDENCE_FOUND",
from_status=ArticleWorkflowStatus.RESEARCH_RUNNING,
from_status=from_status,
to_status=ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
actor_user_id=None,
payload={"reasons": reasons},
created_at=_now(),
created_at=now,
)
if not reasons and article.status == ArticleWorkflowStatus.RESEARCH_RUNNING:
now = _now()
article = repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="EVIDENCE_MATRIX_READY",
from_status=ArticleWorkflowStatus.RESEARCH_RUNNING,
to_status=ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
actor_user_id=None,
payload={
"evidence_count": len(evidence),
"claim_count": len(claims),
},
created_at=now,
)
return EvidenceMatrixResponse(
@@ -133,21 +155,69 @@ def remove_evidence(
def start_draft(repository: object, *, article_id: UUID) -> AgentJobListResponse:
article = repository.articles.get(article_id)
if article.status != ArticleWorkflowStatus.EVIDENCE_MATRIX_READY:
raise PermissionError("Evidence matrix must be ready before draft production")
matrix = get_evidence_matrix(repository, article_id=article_id)
if matrix.insufficient_evidence_reasons:
raise PermissionError("Acceptable evidence is required before draft production")
if matrix.article.status != ArticleWorkflowStatus.EVIDENCE_MATRIX_READY:
raise PermissionError("Evidence matrix must be ready before draft production")
job = repository.agent_jobs.create(
approved_plan = _approved_plan(repository, article_id=article_id)
if approved_plan is None or not approved_plan.sections:
raise PermissionError("Approved plan with sections is required before draft production")
claim_evidence_by_section = _claim_evidence_ids_by_section(matrix.claims)
now = _now()
repository.articles.update_status(
article_id=article_id,
parent_job_id=None,
attempt=1,
job_type=AgentJobType.DRAFT_ASSEMBLY,
agent_profile="fake-draft-assembly",
status=AgentJobStatus.QUEUED,
input_files=[{"path": "inputs/evidence-matrix.json", "content_hash": None}],
queued_at=_now(),
status=ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING,
updated_at=now,
)
return AgentJobListResponse(jobs=[job])
repository.articles.create_workflow_event(
article_id=article_id,
event_type="PARALLEL_PRODUCTION_STARTED",
from_status=ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
to_status=ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING,
actor_user_id=None,
payload={"section_count": len(approved_plan.sections)},
created_at=now,
)
jobs = []
for section in approved_plan.sections:
used_evidence_ids = claim_evidence_by_section.get(str(section.id), [])
jobs.append(
repository.agent_jobs.create(
article_id=article_id,
parent_job_id=None,
attempt=1,
job_type=AgentJobType.SECTION_SCAFFOLD,
agent_profile="fake-section-scaffold",
status=AgentJobStatus.QUEUED,
input_files=[
{"path": "inputs/evidence-matrix.json", "content_hash": None},
{"path": "inputs/approved-plan.json", "content_hash": None},
{"path": f"inputs/sections/{section.id}.json", "content_hash": None},
],
payload={
"artifact_key": f"section:{section.id}",
"artifact_label": section.heading,
"artifact_type": "section_scaffold",
"section_id": str(section.id),
"heading": section.heading,
"used_evidence_ids": used_evidence_ids,
"unsupported_claims": [],
"suggested_visuals": [],
"draft_markdown": "",
},
queued_at=now,
)
)
return AgentJobListResponse(jobs=jobs)
def approve_final(repository: object, *, article_id: UUID) -> ArticleCreateResponse:
@@ -212,6 +282,27 @@ def _resolve_manifest_id(repository: object, *, article_id: UUID) -> UUID:
raise ValueError("Research manifest is required before adding manual evidence")
def _approved_plan(repository: object, article_id: UUID) -> object | None:
for plan in reversed(repository.article_plans.list_for_article(article_id)):
if plan.status == PlanReviewStatus.APPROVED:
return plan
return None
def _claim_evidence_ids_by_section(claims: list[object]) -> dict[str, list[str]]:
mapping: dict[str, list[str]] = {}
for claim in claims:
if claim.section_id is None:
continue
section_id = str(claim.section_id)
bucket = mapping.setdefault(section_id, [])
for evidence_id in claim.evidence_item_ids:
encoded = str(evidence_id)
if encoded not in bucket:
bucket.append(encoded)
return mapping
def _insufficient_reasons(claims: list[object]) -> list[str]:
reasons: list[str] = []
for claim in claims:
+1
View File
@@ -67,6 +67,7 @@ def start_research_run(
status=AgentJobStatus.SUCCEEDED,
workspace_path=None,
output_files=[{"path": artifact["object_key"]} for artifact in artifacts],
payload={},
error_category=None,
error_message=None,
stdout="fake research fetch completed\n",
@@ -34,7 +34,12 @@ from .models import (
ClaimSummary,
CurrentUser,
CurrentUserResponse,
DraftAssembleResponse,
DraftFaqItem,
DraftListResponse,
DraftResponse,
DraftSummary,
DraftUpdateRequest,
EvidenceSummary,
EvidenceCreateRequest,
EvidenceMatrixResponse,
@@ -102,7 +107,12 @@ __all__ = [
"ClaimSupportStatus",
"CurrentUser",
"CurrentUserResponse",
"DraftAssembleResponse",
"DraftFaqItem",
"DraftListResponse",
"DraftResponse",
"DraftSummary",
"DraftUpdateRequest",
"EvidenceSummary",
"EvidenceCreateRequest",
"EvidenceMatrixResponse",
@@ -361,6 +361,11 @@ class EvidenceMatrixResponse(ContractModel):
insufficient_evidence_reasons: list[str] = Field(default_factory=list)
class DraftFaqItem(ContractModel):
question: str = Field(min_length=1)
answer: str = Field(min_length=1)
class DraftSummary(ContractModel):
id: UUID
article_id: UUID
@@ -370,7 +375,14 @@ class DraftSummary(ContractModel):
meta_title: str | None = None
meta_description: str | None = None
body_object_key: str | None = None
body_markdown: str = ""
faq_items: list[DraftFaqItem] = Field(default_factory=list)
visual_placeholders: list[str] = Field(default_factory=list)
evidence_references: list[UUID] = Field(default_factory=list)
unsupported_claim_warnings: list[str] = Field(default_factory=list)
based_on_draft_id: UUID | None = None
status: ArticleWorkflowStatus
created_at: datetime
updated_at: datetime
@@ -429,6 +441,7 @@ class AgentJobSummary(ContractModel):
workspace_path: str | None = None
input_files: list[RunnerFileRef] = Field(default_factory=list)
output_files: list[RunnerFileRef] = Field(default_factory=list)
payload: JsonObject = Field(default_factory=dict)
error_category: AgentJobErrorCategory | None = None
error_message: str | None = None
stdout: str = ""
@@ -461,6 +474,29 @@ class AgentJobOutput(ContractModel):
payload: JsonObject = Field(default_factory=dict)
class DraftAssembleResponse(ContractModel):
draft: DraftSummary
class DraftListResponse(ContractModel):
drafts: list[DraftSummary] = Field(default_factory=list)
class DraftResponse(ContractModel):
draft: DraftSummary
class DraftUpdateRequest(ContractModel):
title: str | None = Field(default=None, min_length=1)
meta_title: str | None = None
meta_description: str | None = None
body_markdown: str | None = None
faq_items: list[DraftFaqItem] | None = None
visual_placeholders: list[str] | None = None
evidence_references: list[UUID] | None = None
unsupported_claim_warnings: list[str] | None = None
class ArticleDetailResponse(ContractModel):
article: ArticleSummary
target_site: TargetSiteConfig | None = None
@@ -40,7 +40,12 @@ from .models import (
ClaimSummary,
CurrentUser,
CurrentUserResponse,
DraftAssembleResponse,
DraftFaqItem,
DraftListResponse,
DraftResponse,
DraftSummary,
DraftUpdateRequest,
EvidenceSummary,
EvidenceCreateRequest,
EvidenceMatrixResponse,
@@ -132,6 +137,11 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
ClaimSummary,
EvidenceMatrixResponse,
DraftSummary,
DraftFaqItem,
DraftAssembleResponse,
DraftListResponse,
DraftResponse,
DraftUpdateRequest,
AssetSummary,
ReviewSummary,
ReviewActionResponse,
@@ -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
@@ -1413,6 +1591,7 @@ class AgentJobsRepository:
agent_profile: str,
status: AgentJobStatus,
input_files: list[JsonObject],
payload: JsonObject | None = None,
queued_at: datetime,
) -> AgentJobSummary:
job_id = uuid4()
@@ -1428,6 +1607,7 @@ class AgentJobsRepository:
agent_profile,
status,
input_files,
payload,
queued_at
)
VALUES (
@@ -1439,6 +1619,7 @@ class AgentJobsRepository:
{placeholder},
{placeholder},
{placeholder}{json_cast},
{placeholder}{json_cast},
{placeholder}
)
"""
@@ -1454,6 +1635,7 @@ class AgentJobsRepository:
agent_profile,
status.value,
_json_value(input_files),
_json_value(payload or {}),
_datetime_value(queued_at),
),
)
@@ -1472,6 +1654,21 @@ class AgentJobsRepository:
return [_agent_job_summary_from_row(row) for row in rows]
def list_for_article(self, article_id: UUID) -> list[AgentJobSummary]:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
rows = connection.execute(
f"""
SELECT {self._select_columns()}
FROM agent_jobs
WHERE article_id = {placeholder}
ORDER BY queued_at DESC
""",
(str(article_id),),
).fetchall()
return [_agent_job_summary_from_row(row) for row in rows]
def get(self, job_id: UUID) -> AgentJobSummary:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
@@ -1528,6 +1725,7 @@ class AgentJobsRepository:
status: AgentJobStatus,
workspace_path: str | None,
output_files: list[JsonObject],
payload: JsonObject | None,
error_category: AgentJobErrorCategory | None,
error_message: str | None,
stdout: str,
@@ -1550,6 +1748,7 @@ class AgentJobsRepository:
status = {placeholder},
workspace_path = {placeholder},
output_files = {placeholder}{json_cast},
payload = {placeholder}{json_cast},
error_category = {placeholder},
error_message = {placeholder},
stdout = {placeholder},
@@ -1563,6 +1762,7 @@ class AgentJobsRepository:
status.value,
workspace_path,
_json_value(output_files),
_json_value(payload or {}),
_agent_error_category_value(error_category),
error_message,
stdout,
@@ -1614,6 +1814,7 @@ class AgentJobsRepository:
workspace_path,
input_files,
output_files,
payload,
error_category,
error_message,
stdout,
@@ -2153,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"),
@@ -2165,6 +2395,7 @@ def _agent_job_summary_from_row(row: Any) -> AgentJobSummary:
workspace_path=_row_value(row, "workspace_path"),
input_files=_json_from_row(row, "input_files"),
output_files=_json_from_row(row, "output_files"),
payload=_json_from_row(row, "payload"),
error_category=_row_value(row, "error_category"),
error_message=_row_value(row, "error_message"),
stdout=_row_value(row, "stdout"),
+21
View File
@@ -215,12 +215,24 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
meta_title TEXT,
meta_description TEXT,
body_object_key TEXT,
body_markdown TEXT NOT NULL DEFAULT '',
faq_items JSONB NOT NULL DEFAULT '[]'::jsonb,
visual_placeholders JSONB NOT NULL DEFAULT '[]'::jsonb,
evidence_references JSONB NOT NULL DEFAULT '[]'::jsonb,
unsupported_claim_warnings JSONB NOT NULL DEFAULT '[]'::jsonb,
based_on_draft_id UUID REFERENCES article_drafts(id) ON DELETE SET NULL,
status TEXT NOT NULL CHECK (status IN ({ARTICLE_STATUS_VALUES})),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (article_id, version)
)
""",
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS body_markdown TEXT NOT NULL DEFAULT ''",
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS faq_items JSONB NOT NULL DEFAULT '[]'::jsonb",
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS visual_placeholders JSONB NOT NULL DEFAULT '[]'::jsonb",
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS evidence_references JSONB NOT NULL DEFAULT '[]'::jsonb",
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS unsupported_claim_warnings JSONB NOT NULL DEFAULT '[]'::jsonb",
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS based_on_draft_id UUID REFERENCES article_drafts(id) ON DELETE SET NULL",
f"""
CREATE TABLE IF NOT EXISTS assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -260,6 +272,7 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
workspace_path TEXT,
input_files JSONB NOT NULL DEFAULT '[]'::jsonb,
output_files JSONB NOT NULL DEFAULT '[]'::jsonb,
payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
error_category TEXT CHECK (
error_category IS NULL
OR error_category IN ({AGENT_JOB_ERROR_CATEGORY_VALUES})
@@ -280,6 +293,7 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS stderr TEXT NOT NULL DEFAULT ''",
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS exit_code INTEGER",
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS duration_ms INTEGER",
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS payload JSONB NOT NULL DEFAULT '{}'::jsonb",
"""
CREATE TABLE IF NOT EXISTS research_run_manifests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -523,6 +537,12 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
meta_title TEXT,
meta_description TEXT,
body_object_key TEXT,
body_markdown TEXT NOT NULL DEFAULT '',
faq_items TEXT NOT NULL DEFAULT '[]',
visual_placeholders TEXT NOT NULL DEFAULT '[]',
evidence_references TEXT NOT NULL DEFAULT '[]',
unsupported_claim_warnings TEXT NOT NULL DEFAULT '[]',
based_on_draft_id TEXT,
status TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -568,6 +588,7 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
workspace_path TEXT,
input_files TEXT NOT NULL DEFAULT '[]',
output_files TEXT NOT NULL DEFAULT '[]',
payload TEXT NOT NULL DEFAULT '{}',
error_category TEXT,
error_message TEXT,
stdout TEXT NOT NULL DEFAULT '',
+2
View File
@@ -12,6 +12,7 @@ from src.presentation.routes.agent_jobs import router as agent_jobs_router
from src.presentation.routes.articles import router as articles_router
from src.presentation.routes.auth import router as auth_router
from src.presentation.routes.boundary_questions import router as boundary_questions_router
from src.presentation.routes.drafts import router as drafts_router
from src.presentation.routes.evidence import router as evidence_router
from src.presentation.routes.plans import router as plans_router
from src.presentation.routes.sites import router as sites_router
@@ -23,6 +24,7 @@ app.include_router(articles_router)
app.include_router(boundary_questions_router)
app.include_router(plans_router)
app.include_router(evidence_router)
app.include_router(drafts_router)
app.include_router(agent_jobs_router)
app.include_router(internal_agent_jobs_router)
app.include_router(sites_router)
@@ -0,0 +1,93 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from src.application.drafts import assemble_draft, get_draft, list_drafts, update_draft
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
CurrentUser,
DraftAssembleResponse,
DraftListResponse,
DraftResponse,
DraftUpdateRequest,
)
from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles
router = APIRouter(prefix="/api", tags=["drafts"])
@router.post(
"/articles/{article_id}/draft/assemble",
response_model=DraftAssembleResponse,
status_code=status.HTTP_201_CREATED,
)
def post_assemble_draft(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> DraftAssembleResponse:
try:
return assemble_draft(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except (PermissionError, ValueError) as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.get(
"/articles/{article_id}/drafts",
response_model=DraftListResponse,
)
def get_article_drafts(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> DraftListResponse:
try:
return list_drafts(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.get(
"/articles/{article_id}/drafts/{draft_id}",
response_model=DraftResponse,
)
def get_article_draft(
article_id: UUID,
draft_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> DraftResponse:
try:
return get_draft(repository, article_id=article_id, draft_id=draft_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.patch(
"/articles/{article_id}/drafts/{draft_id}",
response_model=DraftResponse,
)
def patch_article_draft(
article_id: UUID,
draft_id: UUID,
request: DraftUpdateRequest,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> DraftResponse:
try:
return update_draft(
repository,
article_id=article_id,
draft_id=draft_id,
request=request,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@@ -0,0 +1,395 @@
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from pathlib import Path
from typing import Any
from uuid import uuid4
from fastapi.testclient import TestClient
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
from src.application.seed_data import seed_reference_data # noqa: E402
from src.infrastructure.repositories import open_backend_repository # noqa: E402
from src.presentation.dependencies import get_repository # noqa: E402
from src.presentation.main import app # noqa: E402
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_ADMIN_EMAIL = "admin@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class DraftAssemblyPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects")
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'draft-assembly.db'}"
self.repository = open_backend_repository(dsn)
self.repository.setup()
seed_reference_data(self.repository)
app.dependency_overrides[get_repository] = lambda: self.repository
self.client = TestClient(app)
def tearDown(self) -> None:
app.dependency_overrides.clear()
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
self.tmp_dir.cleanup()
def test_assemble_draft_includes_all_sections_in_order_and_full_payload(self) -> None:
article_id, section_headings = self._prepare_article_for_draft_assembly()
section_jobs = self._start_parallel_production(article_id)
for index, job in enumerate(section_jobs, start=1):
if index == 1:
draft_markdown = (
f"## {job['payload']['heading']}\n\n"
"Use [official documentation](https://example.com/docs) for setup.\n\n"
"| Step | Result |\n"
"| --- | --- |\n"
"| 1 | Ready |\n\n"
"![Existing mock](placeholder://existing/mock)\n"
)
else:
draft_markdown = (
f"## {job['payload']['heading']}\n\n"
f"Body for section {index}."
)
completed = self._complete_job(
job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": f"outputs/section-{index}.md"}],
"payload": {
"used_evidence_ids": job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"suggested_visuals": [{"title": f"Visual {index}"}],
"draft_markdown": draft_markdown,
},
},
)
self.assertEqual("SUCCEEDED", completed["status"])
assemble_response = self.client.post(
f"/api/articles/{article_id}/draft/assemble",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, assemble_response.status_code, assemble_response.text)
draft = assemble_response.json()["draft"]
self.assertEqual(1, draft["version"])
self.assertTrue(draft["meta_title"])
self.assertTrue(draft["meta_description"])
self.assertGreaterEqual(len(draft["faq_items"]), 1)
self.assertGreaterEqual(len(draft["visual_placeholders"]), 1)
self.assertGreaterEqual(len(draft["evidence_references"]), 1)
self.assertIn("unsupported_claim_warnings", draft)
body_markdown = draft["body_markdown"]
cursor = -1
for heading in section_headings:
position = body_markdown.find(f"## {heading}")
self.assertGreater(position, cursor)
cursor = position
self.assertIn("## Evidence References", body_markdown)
self.assertIn("## Visual Placeholders", body_markdown)
self.assertIn("## FAQ", body_markdown)
self.assertIn("| Step | Result |", body_markdown)
self.assertIn("[official documentation](https://example.com/docs)", body_markdown)
self.assertIn("![Existing mock](placeholder://existing/mock)", body_markdown)
drafts_response = self.client.get(
f"/api/articles/{article_id}/drafts",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, drafts_response.status_code, drafts_response.text)
self.assertEqual(1, len(drafts_response.json()["drafts"]))
draft_id = draft["id"]
draft_detail_response = self.client.get(
f"/api/articles/{article_id}/drafts/{draft_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, draft_detail_response.status_code, draft_detail_response.text)
self.assertEqual(draft["body_markdown"], draft_detail_response.json()["draft"]["body_markdown"])
def test_patch_creates_new_immutable_draft_version(self) -> None:
article_id, _ = self._prepare_article_for_draft_assembly()
section_jobs = self._start_parallel_production(article_id)
self._complete_all_sections_success(section_jobs)
first_draft = self._assemble(article_id)
patch_response = self.client.patch(
f"/api/articles/{article_id}/drafts/{first_draft['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"meta_title": "Updated metadata title",
"body_markdown": first_draft["body_markdown"] + "\n\nRevision marker.\n",
},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
second_draft = patch_response.json()["draft"]
self.assertEqual(2, second_draft["version"])
self.assertEqual(first_draft["id"], second_draft["based_on_draft_id"])
self.assertEqual("Updated metadata title", second_draft["meta_title"])
self.assertIn("Revision marker.", second_draft["body_markdown"])
first_draft_refresh = self.client.get(
f"/api/articles/{article_id}/drafts/{first_draft['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["draft"]
self.assertEqual(1, first_draft_refresh["version"])
self.assertNotIn("Revision marker.", first_draft_refresh["body_markdown"])
self.assertNotEqual(second_draft["meta_title"], first_draft_refresh["meta_title"])
list_response = self.client.get(
f"/api/articles/{article_id}/drafts",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, list_response.status_code, list_response.text)
versions = [item["version"] for item in list_response.json()["drafts"]]
self.assertEqual([2, 1], versions)
def test_assemble_fails_with_clear_conflict_when_scaffold_missing(self) -> None:
article_id, section_headings = self._prepare_article_for_draft_assembly()
section_jobs = self._start_parallel_production(article_id)
self.assertGreaterEqual(len(section_jobs), 2)
for job in section_jobs[:-1]:
completed = self._complete_job(
job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/ready.md"}],
"payload": {
"used_evidence_ids": job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": f"## {job['payload']['heading']}\n\nReady.",
},
},
)
self.assertEqual("SUCCEEDED", completed["status"])
missing_heading = section_headings[-1]
assemble_response = self.client.post(
f"/api/articles/{article_id}/draft/assemble",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(409, assemble_response.status_code, assemble_response.text)
self.assertIn("Missing successful section scaffolds", assemble_response.text)
self.assertIn(missing_heading, assemble_response.text)
def test_assemble_carries_unsupported_claim_warnings_from_scaffold_attempts(self) -> None:
article_id, _ = self._prepare_article_for_draft_assembly()
section_jobs = self._start_parallel_production(article_id)
self.assertGreaterEqual(len(section_jobs), 1)
first_job = section_jobs[0]
failed = self._complete_job(
first_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/failed.md"}],
"payload": {
"used_evidence_ids": first_job["payload"]["used_evidence_ids"],
"unsupported_claims": [
{"claim_text": "Unverified migration metric", "risk_level": "high"}
],
"draft_markdown": "Content with unsupported claim",
},
},
)
self.assertEqual("FAILED", failed["status"])
retry_response = self.client.post(
f"/api/agent-jobs/{first_job['id']}/retry",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(201, retry_response.status_code, retry_response.text)
retry_job = retry_response.json()["job"]
retry_completed = self._complete_job(
retry_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/retry.md"}],
"payload": {
"used_evidence_ids": retry_job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": f"## {retry_job['payload']['heading']}\n\nClean section.",
},
},
)
self.assertEqual("SUCCEEDED", retry_completed["status"])
for job in section_jobs[1:]:
self._complete_job(
job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/ok.md"}],
"payload": {
"used_evidence_ids": job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": f"## {job['payload']['heading']}\n\nReady.",
},
},
)
draft = self._assemble(article_id)
warnings = draft["unsupported_claim_warnings"]
self.assertTrue(any("Unverified migration metric" in warning for warning in warnings))
self.assertIn("## Unsupported Claim Warnings", draft["body_markdown"])
def test_draft_endpoints_return_404_for_missing_resources(self) -> None:
random_article_id = uuid4()
list_response = self.client.get(
f"/api/articles/{random_article_id}/drafts",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(404, list_response.status_code, list_response.text)
article_id, _ = self._prepare_article_for_draft_assembly()
random_draft_id = uuid4()
detail_response = self.client.get(
f"/api/articles/{article_id}/drafts/{random_draft_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(404, detail_response.status_code, detail_response.text)
patch_response = self.client.patch(
f"/api/articles/{article_id}/drafts/{random_draft_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"title": "Will fail"},
)
self.assertEqual(404, patch_response.status_code, patch_response.text)
def _assemble(self, article_id: str) -> dict[str, Any]:
response = self.client.post(
f"/api/articles/{article_id}/draft/assemble",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, response.status_code, response.text)
return response.json()["draft"]
def _complete_all_sections_success(self, section_jobs: list[dict[str, Any]]) -> None:
for index, job in enumerate(section_jobs, start=1):
completed = self._complete_job(
job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": f"outputs/{index}.md"}],
"payload": {
"used_evidence_ids": job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": f"## {job['payload']['heading']}\n\nSection {index}.",
},
},
)
self.assertEqual("SUCCEEDED", completed["status"])
def _complete_job(self, job_id: str, *, output: dict[str, Any]) -> dict[str, Any]:
response = self.client.post(
f"/internal/agent-jobs/{job_id}/complete",
json={
"workspace_path": f"/tmp/{job_id}",
"stdout": "fake section scaffolding runner\n",
"stderr": "",
"exit_code": 0,
"duration_ms": 1,
"output": output,
},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["job"]
def _start_parallel_production(self, article_id: str) -> list[dict[str, Any]]:
start_response = self.client.post(
f"/api/articles/{article_id}/draft/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(202, start_response.status_code, start_response.text)
jobs = start_response.json()["jobs"]
return [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"]
def _prepare_article_for_draft_assembly(self) -> tuple[str, list[str]]:
article_id, section_headings = self._create_article_with_approved_plan()
self._ensure_evidence_ready(article_id)
return article_id, section_headings
def _ensure_evidence_ready(self, article_id: str) -> None:
research_response = self.client.post(
f"/api/articles/{article_id}/research/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, research_response.status_code, research_response.text)
evidence_response = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
self.assertFalse(evidence_response.json()["insufficient_evidence_reasons"])
def _create_article_with_approved_plan(self) -> tuple[str, list[str]]:
site = self.client.get(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()[0]["site"]
article = self.client.post(
"/api/articles",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"target_site_id": site["id"],
"brief_description": "Assemble draft from section scaffolds with versions.",
"working_title": "Draft Assembly With Versions",
"content_type": "longform_guide",
"primary_keyword": "draft assembly editor preview",
},
).json()["article"]
article_id = article["id"]
questions = self.client.post(
f"/api/articles/{article_id}/boundary-questions/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["questions"]
for question in questions:
if question["is_required"]:
patch_response = self.client.patch(
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"answer": f"Answer for {question['category']}"},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
submit_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/submit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, submit_response.status_code, submit_response.text)
plan_response = self.client.post(
f"/api/articles/{article_id}/plan/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, plan_response.status_code, plan_response.text)
plan = plan_response.json()["plan"]
self.assertGreaterEqual(len(plan["sections"]), 1)
approve_response = self.client.post(
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, approve_response.status_code, approve_response.text)
return article_id, [section["heading"] for section in plan["sections"]]
if __name__ == "__main__":
unittest.main()
@@ -90,7 +90,7 @@ class EvidenceMatrixPublicApiTest(unittest.TestCase):
)
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
body = evidence_response.json()
self.assertEqual("RESEARCH_RUNNING", body["article"]["status"])
self.assertEqual("EVIDENCE_MATRIX_READY", body["article"]["status"])
self.assertFalse(body["insufficient_evidence_reasons"])
self.assertTrue(body["evidence"])
self.assertTrue(body["claims"])
@@ -0,0 +1,321 @@
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from pathlib import Path
from typing import Any
from fastapi.testclient import TestClient
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
from src.application.seed_data import seed_reference_data # noqa: E402
from src.infrastructure.repositories import open_backend_repository # noqa: E402
from src.presentation.dependencies import get_repository # noqa: E402
from src.presentation.main import app # noqa: E402
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_ADMIN_EMAIL = "admin@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class ParallelProductionPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects")
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'parallel-production.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_start_production_requires_evidence_matrix_ready(self) -> None:
article_id, _ = self._create_article_with_approved_plan()
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)
start_response = self.client.post(
f"/api/articles/{article_id}/draft/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(409, start_response.status_code, start_response.text)
self.assertIn("Evidence matrix must be ready", start_response.text)
def test_start_production_creates_one_section_job_per_approved_plan_section(self) -> None:
article_id, approved_section_count = self._prepare_article_for_parallel_production()
start_production_response = self.client.post(
f"/api/articles/{article_id}/draft/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(202, start_production_response.status_code, start_production_response.text)
jobs = start_production_response.json()["jobs"]
section_jobs = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"]
self.assertEqual(
approved_section_count,
len(section_jobs),
"Expected one SECTION_SCAFFOLD job per approved plan section",
)
def test_section_jobs_run_independently_and_can_fail_independently(self) -> None:
article_id, _ = self._prepare_article_for_parallel_production()
created_jobs = self._start_parallel_production(article_id)
section_jobs = [job for job in created_jobs if job["job_type"] == "SECTION_SCAFFOLD"]
self.assertGreaterEqual(len(section_jobs), 2)
success_job = section_jobs[0]
failed_job = section_jobs[1]
complete_success = self._complete_job(
success_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/section-success.md"}],
"payload": {
"used_evidence_ids": success_job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": "Supported section draft",
},
},
)
self.assertEqual("SUCCEEDED", complete_success["status"])
self.assertIsNone(complete_success["error_category"])
unsupported_claims = [
{
"claim_text": "Unverified benchmark introduced during scaffolding.",
"risk_level": "high",
}
]
complete_failure = self._complete_job(
failed_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/section-failed.md"}],
"payload": {
"used_evidence_ids": failed_job["payload"]["used_evidence_ids"],
"unsupported_claims": unsupported_claims,
"draft_markdown": "Draft includes unsupported claim",
},
},
)
self.assertEqual("FAILED", complete_failure["status"])
self.assertEqual("UNSUPPORTED_CLAIMS_FOUND", complete_failure["error_category"])
self.assertEqual(unsupported_claims, complete_failure["payload"]["unsupported_claims"])
def test_retry_failed_section_does_not_rerun_successful_sections(self) -> None:
article_id, _ = self._prepare_article_for_parallel_production()
created_jobs = self._start_parallel_production(article_id)
section_jobs = [job for job in created_jobs if job["job_type"] == "SECTION_SCAFFOLD"]
self.assertGreaterEqual(len(section_jobs), 2)
success_job = self._complete_job(
section_jobs[0]["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/section-success.md"}],
"payload": {"unsupported_claims": []},
},
)
failed_job = self._complete_job(
section_jobs[1]["id"],
output={
"status": "FAILED",
"error_category": "CLI_EXIT_CODE_FAILURE",
"error_message": "Runner failed for one section",
"payload": {"unsupported_claims": []},
},
)
retry_response = self.client.post(
f"/api/agent-jobs/{failed_job['id']}/retry",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(201, retry_response.status_code, retry_response.text)
retry_job = retry_response.json()["job"]
self.assertEqual("QUEUED", retry_job["status"])
self.assertEqual(failed_job["id"], retry_job["parent_job_id"])
self.assertEqual(2, retry_job["attempt"])
detail = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()
successful_descendants = [
job
for job in detail["agent_jobs"]
if job.get("parent_job_id") == success_job["id"]
]
failed_descendants = [
job
for job in detail["agent_jobs"]
if job.get("parent_job_id") == failed_job["id"]
]
self.assertEqual([], successful_descendants)
self.assertEqual(1, len(failed_descendants))
self.assertEqual("QUEUED", failed_descendants[0]["status"])
def test_each_scaffold_payload_lists_used_evidence_ids(self) -> None:
article_id, _ = self._prepare_article_for_parallel_production()
evidence = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["evidence"]
evidence_ids = {item["id"] for item in evidence}
jobs = self._start_parallel_production(article_id)
section_jobs = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"]
self.assertTrue(section_jobs)
self.assertTrue(any(job["payload"]["used_evidence_ids"] for job in section_jobs))
for job in section_jobs:
self.assertIn("used_evidence_ids", job["payload"])
self.assertIsInstance(job["payload"]["used_evidence_ids"], list)
for evidence_id in job["payload"]["used_evidence_ids"]:
self.assertIn(evidence_id, evidence_ids)
def test_unsupported_claims_from_scaffold_are_captured_in_article_detail(self) -> None:
article_id, _ = self._prepare_article_for_parallel_production()
jobs = self._start_parallel_production(article_id)
section_job = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"][0]
unsupported_claims = [
{
"claim_text": "Unverified migration timeline claim",
"risk_level": "medium",
}
]
completed_job = self._complete_job(
section_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/section-with-unsupported.md"}],
"payload": {
"used_evidence_ids": section_job["payload"]["used_evidence_ids"],
"unsupported_claims": unsupported_claims,
"draft_markdown": "Draft with unsupported claim",
},
},
)
self.assertEqual("FAILED", completed_job["status"])
self.assertEqual("UNSUPPORTED_CLAIMS_FOUND", completed_job["error_category"])
detail_response = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, detail_response.status_code, detail_response.text)
article_jobs = detail_response.json()["agent_jobs"]
refreshed_job = [job for job in article_jobs if job["id"] == section_job["id"]][0]
self.assertEqual(unsupported_claims, refreshed_job["payload"]["unsupported_claims"])
self.assertIn("Unsupported claims introduced during scaffolding", refreshed_job["error_message"])
def _start_parallel_production(self, article_id: str) -> 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)
return response.json()["jobs"]
def _complete_job(self, job_id: str, *, output: dict[str, Any]) -> dict[str, Any]:
response = self.client.post(
f"/internal/agent-jobs/{job_id}/complete",
json={
"workspace_path": f"/tmp/{job_id}",
"stdout": "fake section scaffolding runner\n",
"stderr": "",
"exit_code": 0,
"duration_ms": 2,
"output": output,
},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["job"]
def _prepare_article_for_parallel_production(self) -> tuple[str, int]:
article_id, approved_section_count = self._create_article_with_approved_plan()
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.assertFalse(evidence_response.json()["insufficient_evidence_reasons"])
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
return article_id, approved_section_count
def _create_article_with_approved_plan(self) -> tuple[str, int]:
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": "Run parallel production jobs from approved sections.",
"working_title": "Parallel Production Jobs",
"content_type": "longform_guide",
"primary_keyword": "parallel production jobs",
},
).json()["article"]
article_id = article["id"]
questions = self.client.post(
f"/api/articles/{article_id}/boundary-questions/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["questions"]
for question in questions:
if question["is_required"]:
patch_response = self.client.patch(
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"answer": f"Answer for {question['category']}"},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
submit_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/submit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, submit_response.status_code, submit_response.text)
plan_response = self.client.post(
f"/api/articles/{article_id}/plan/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, plan_response.status_code, plan_response.text)
plan = plan_response.json()["plan"]
self.assertGreaterEqual(len(plan["sections"]), 1)
approve_response = self.client.post(
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, approve_response.status_code, approve_response.text)
return article_id, len(plan["sections"])
if __name__ == "__main__":
unittest.main()
+38
View File
@@ -344,6 +344,44 @@ main {
padding: 10px 0;
}
.draftEditor {
margin-top: 28px;
}
.draftEditor .detailGrid select {
width: 100%;
border-radius: 6px;
border: 1px solid #d0d5dd;
padding: 10px;
font: inherit;
}
.previewPane {
border: 1px solid #d0d5dd;
border-radius: 8px;
background: #ffffff;
padding: 14px;
}
.previewImage {
margin: 0;
border: 1px dashed #d0d5dd;
border-radius: 8px;
padding: 10px;
}
.previewImage img {
display: block;
max-width: 100%;
max-height: 180px;
}
.previewImage figcaption {
margin-top: 6px;
color: #667085;
font-size: 12px;
}
@media (max-width: 640px) {
main {
padding: 28px;
@@ -1,4 +1,4 @@
import type { ArticleDetailResponse, WorkflowEventSummary } from "@pipeline/shared";
import type { AgentJobSummary, ArticleDetailResponse, WorkflowEventSummary } from "@pipeline/shared";
export type ArticleTimelineItem = Pick<
WorkflowEventSummary,
@@ -15,6 +15,19 @@ export type DetailSummary = {
targetSite: string;
updatedAt: string;
timeline: ArticleTimelineItem[];
productionArtifacts: ProductionArtifactRow[];
};
export type ProductionArtifactRow = {
jobId: string;
artifactKey: string;
artifactLabel: string;
status: string;
attempt: number;
jobType: string;
usedEvidenceIds: string[];
unsupportedClaims: string[];
errorMessage: string | null;
};
export function buildArticleTimeline(detail: ArticleDetailResponse): ArticleTimelineItem[] {
@@ -38,5 +51,64 @@ export function buildDetailSummary(detail: ArticleDetailResponse): DetailSummary
targetSite: detail.target_site?.name ?? "Unknown",
updatedAt: detail.article.updated_at,
timeline: buildArticleTimeline(detail),
productionArtifacts: buildProductionArtifactRows(detail.agent_jobs ?? []),
};
}
export function buildProductionArtifactRows(
jobs: readonly AgentJobSummary[],
): ProductionArtifactRow[] {
return jobs
.filter((job) => job.job_type === "SECTION_SCAFFOLD")
.map((job) => {
const payload = (job.payload ?? {}) as Record<string, unknown>;
const artifactKey = stringValue(payload.artifact_key) || `job:${job.id}`;
const artifactLabel =
stringValue(payload.artifact_label)
|| stringValue(payload.heading)
|| artifactKey;
return {
jobId: job.id,
artifactKey,
artifactLabel,
status: job.status,
attempt: job.attempt ?? 1,
jobType: job.job_type,
usedEvidenceIds: stringArray(payload.used_evidence_ids),
unsupportedClaims: unsupportedClaims(payload.unsupported_claims),
errorMessage: job.error_message ?? null,
};
})
.sort((left, right) => left.artifactLabel.localeCompare(right.artifactLabel));
}
function stringValue(value: unknown): string {
return typeof value === "string" ? value : "";
}
function stringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is string => typeof item === "string");
}
function unsupportedClaims(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const claims: string[] = [];
for (const item of value) {
if (typeof item === "string") {
claims.push(item);
continue;
}
if (typeof item === "object" && item !== null && "claim_text" in item) {
const claimText = item.claim_text;
if (typeof claimText === "string") {
claims.push(claimText);
}
}
}
return claims;
}
@@ -8,6 +8,7 @@ type DetailShellProps = {
export function ArticleDetailShell({ summary }: DetailShellProps) {
const timeline = summary.timeline;
const productionArtifacts = summary.productionArtifacts;
return (
<section>
@@ -66,6 +67,46 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
))}
{timeline.length === 0 ? <li>No workflow events yet.</li> : null}
</ul>
<h3>Production artifacts</h3>
<table className="table">
<thead>
<tr>
<th>Artifact</th>
<th>Status</th>
<th>Attempt</th>
<th>Used evidence IDs</th>
<th>Unsupported claims</th>
</tr>
</thead>
<tbody>
{productionArtifacts.map((artifact) => (
<tr key={artifact.jobId}>
<td>
<div>{artifact.artifactLabel}</div>
<div>{artifact.artifactKey}</div>
</td>
<td>{artifact.status}</td>
<td>{artifact.attempt}</td>
<td>
{artifact.usedEvidenceIds.length
? artifact.usedEvidenceIds.join(", ")
: "—"}
</td>
<td>
{artifact.unsupportedClaims.length
? artifact.unsupportedClaims.join("; ")
: artifact.errorMessage ?? "—"}
</td>
</tr>
))}
{productionArtifacts.length === 0 ? (
<tr>
<td colSpan={5}>No production artifacts yet.</td>
</tr>
) : null}
</tbody>
</table>
</section>
);
}
@@ -0,0 +1,170 @@
import type { DraftFaqItem, DraftSummary } from "@pipeline/shared";
export type DraftPreviewBlock =
| { type: "heading"; level: number; text: string }
| { type: "paragraph"; text: string; links: DraftPreviewLink[] }
| { type: "table"; headers: string[]; rows: string[][] }
| { type: "image"; alt: string; src: string }
| { type: "faq"; items: DraftFaqItem[] };
export type DraftPreviewLink = {
label: string;
href: string;
};
export type DraftVersionComparison = {
leftVersion: number;
rightVersion: number;
titleChanged: boolean;
metaTitleChanged: boolean;
metaDescriptionChanged: boolean;
bodyChanged: boolean;
changedLineCount: number;
};
export function selectDraftById(
drafts: readonly DraftSummary[],
draftId: string | null,
): DraftSummary | null {
if (!draftId) {
return drafts[0] ?? null;
}
return drafts.find((draft) => draft.id === draftId) ?? null;
}
export function compareDraftVersions(
left: DraftSummary,
right: DraftSummary,
): DraftVersionComparison {
const leftBodyMarkdown = left.body_markdown ?? "";
const rightBodyMarkdown = right.body_markdown ?? "";
const leftLines = normalizedLines(leftBodyMarkdown);
const rightLines = normalizedLines(rightBodyMarkdown);
return {
leftVersion: left.version,
rightVersion: right.version,
titleChanged: left.title !== right.title,
metaTitleChanged: (left.meta_title ?? "") !== (right.meta_title ?? ""),
metaDescriptionChanged:
(left.meta_description ?? "") !== (right.meta_description ?? ""),
bodyChanged: leftBodyMarkdown !== rightBodyMarkdown,
changedLineCount: changedLineCount(leftLines, rightLines),
};
}
export function parseDraftPreview(
markdown: string,
faqItems: readonly DraftFaqItem[],
): DraftPreviewBlock[] {
const lines = markdown.split(/\r?\n/);
const blocks: DraftPreviewBlock[] = [];
let index = 0;
while (index < lines.length) {
const line = lines[index].trim();
if (!line) {
index += 1;
continue;
}
if (line.startsWith("#")) {
const match = line.match(/^(#{1,6})\s+(.+)$/);
if (match) {
blocks.push({
type: "heading",
level: match[1].length,
text: match[2].trim(),
});
index += 1;
continue;
}
}
if (line.startsWith("![") && line.includes("](") && line.endsWith(")")) {
const match = line.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
if (match) {
blocks.push({
type: "image",
alt: match[1],
src: match[2],
});
index += 1;
continue;
}
}
if (line.startsWith("|") && index + 1 < lines.length && lines[index + 1].includes("|")) {
const tableLines: string[] = [];
while (index < lines.length && lines[index].trim().startsWith("|")) {
tableLines.push(lines[index].trim());
index += 1;
}
if (tableLines.length >= 2) {
const headers = parseTableRow(tableLines[0]);
const rows = tableLines.slice(2).map(parseTableRow);
blocks.push({
type: "table",
headers,
rows,
});
}
continue;
}
const paragraphLines: string[] = [];
while (index < lines.length && lines[index].trim()) {
paragraphLines.push(lines[index].trim());
index += 1;
}
const paragraphText = paragraphLines.join(" ");
blocks.push({
type: "paragraph",
text: paragraphText,
links: extractLinks(paragraphText),
});
}
if (faqItems.length > 0) {
blocks.push({
type: "faq",
items: [...faqItems],
});
}
return blocks;
}
function normalizedLines(value: string): string[] {
return value
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
function changedLineCount(left: string[], right: string[]): number {
const limit = Math.max(left.length, right.length);
let changes = 0;
for (let index = 0; index < limit; index += 1) {
if ((left[index] ?? "") !== (right[index] ?? "")) {
changes += 1;
}
}
return changes;
}
function parseTableRow(row: string): string[] {
return row
.slice(1, row.endsWith("|") ? -1 : row.length)
.split("|")
.map((cell) => cell.trim());
}
function extractLinks(text: string): DraftPreviewLink[] {
const links: DraftPreviewLink[] = [];
const pattern = /\[([^\]]+)\]\(([^)]+)\)/g;
let match: RegExpExecArray | null = pattern.exec(text);
while (match) {
links.push({ label: match[1], href: match[2] });
match = pattern.exec(text);
}
return links;
}
@@ -0,0 +1,299 @@
"use client";
import { useMemo, useState } from "react";
import type { DraftSummary } from "@pipeline/shared";
import { ApiError, assembleDraft, updateDraft } from "@/shared/pipeline-api";
import {
compareDraftVersions,
parseDraftPreview,
selectDraftById,
} from "./model";
type DraftEditorPanelProps = {
articleId: string;
initialDrafts: readonly DraftSummary[];
};
type DraftEditorState = {
title: string;
metaTitle: string;
metaDescription: string;
bodyMarkdown: string;
};
export function DraftEditorPanel({ articleId, initialDrafts }: DraftEditorPanelProps) {
const [drafts, setDrafts] = useState<DraftSummary[]>(() => [...initialDrafts]);
const [selectedDraftId, setSelectedDraftId] = useState<string | null>(
initialDrafts[0]?.id ?? null,
);
const [compareDraftId, setCompareDraftId] = useState<string | null>(null);
const [message, setMessage] = useState("");
const [isBusy, setIsBusy] = useState(false);
const [editor, setEditor] = useState<DraftEditorState>(() =>
createEditorState(initialDrafts[0] ?? null),
);
const selectedDraft = useMemo(
() => selectDraftById(drafts, selectedDraftId),
[drafts, selectedDraftId],
);
const compareDraft = useMemo(
() => selectDraftById(drafts, compareDraftId),
[drafts, compareDraftId],
);
const previewBlocks = useMemo(
() => parseDraftPreview(editor.bodyMarkdown, selectedDraft?.faq_items ?? []),
[editor.bodyMarkdown, selectedDraft],
);
const comparison = useMemo(() => {
if (!selectedDraft || !compareDraft || selectedDraft.id === compareDraft.id) {
return null;
}
return compareDraftVersions(selectedDraft, compareDraft);
}, [selectedDraft, compareDraft]);
function onSelectDraft(draftId: string) {
setSelectedDraftId(draftId);
setCompareDraftId((current) => (current === draftId ? null : current));
const nextDraft = selectDraftById(drafts, draftId);
setEditor(createEditorState(nextDraft));
}
async function onAssembleDraft() {
setIsBusy(true);
setMessage("");
try {
const response = await assembleDraft(articleId);
setDrafts((current) => [response.draft, ...current]);
setSelectedDraftId(response.draft.id);
setEditor(createEditorState(response.draft));
setMessage(`Draft v${response.draft.version} assembled.`);
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Failed to assemble draft.");
} finally {
setIsBusy(false);
}
}
async function onSaveDraft() {
if (!selectedDraft) {
setMessage("Assemble draft before editing.");
return;
}
setIsBusy(true);
setMessage("");
try {
const response = await updateDraft(articleId, selectedDraft.id, {
title: editor.title,
meta_title: editor.metaTitle,
meta_description: editor.metaDescription,
body_markdown: editor.bodyMarkdown,
});
setDrafts((current) => [response.draft, ...current]);
setSelectedDraftId(response.draft.id);
setEditor(createEditorState(response.draft));
setMessage(`Saved draft v${response.draft.version}.`);
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Failed to save draft.");
} finally {
setIsBusy(false);
}
}
return (
<section className="draftEditor">
<div className="sectionHeader">
<div>
<h2>Draft editor</h2>
<p>{drafts.length ? `${drafts.length} version(s)` : "No drafts yet"}</p>
</div>
<button type="button" onClick={onAssembleDraft} disabled={isBusy}>
{isBusy ? "Working..." : "Assemble draft"}
</button>
</div>
<div className="detailGrid">
<div>
<dt>Selected draft</dt>
<dd>
<select
value={selectedDraftId ?? ""}
onChange={(event) => onSelectDraft(event.target.value)}
disabled={!drafts.length || isBusy}
>
{drafts.length === 0 ? <option value="">No drafts</option> : null}
{drafts.map((draft) => (
<option key={draft.id} value={draft.id}>
v{draft.version} • {draft.title}
</option>
))}
</select>
</dd>
</div>
<div>
<dt>Compare with</dt>
<dd>
<select
value={compareDraftId ?? ""}
onChange={(event) => setCompareDraftId(event.target.value || null)}
disabled={!drafts.length || isBusy}
>
<option value="">No comparison</option>
{drafts
.filter((draft) => draft.id !== selectedDraftId)
.map((draft) => (
<option key={draft.id} value={draft.id}>
v{draft.version}
</option>
))}
</select>
</dd>
</div>
</div>
{comparison ? (
<p className="formSuccess">
Compare v{comparison.leftVersion} vs v{comparison.rightVersion}: changed lines{" "}
{comparison.changedLineCount}, title {comparison.titleChanged ? "changed" : "same"},
meta title {comparison.metaTitleChanged ? "changed" : "same"}, description{" "}
{comparison.metaDescriptionChanged ? "changed" : "same"}.
</p>
) : null}
{message ? <p className={message.startsWith("Failed") ? "formError" : "formSuccess"}>{message}</p> : null}
<form
className="articleForm"
onSubmit={(event) => {
event.preventDefault();
void onSaveDraft();
}}
>
<label htmlFor="draft-title">Title</label>
<input
id="draft-title"
value={editor.title}
onChange={(event) =>
setEditor((current) => ({ ...current, title: event.target.value }))
}
/>
<label htmlFor="draft-meta-title">Meta title</label>
<input
id="draft-meta-title"
value={editor.metaTitle}
onChange={(event) =>
setEditor((current) => ({ ...current, metaTitle: event.target.value }))
}
/>
<label htmlFor="draft-meta-description">Meta description</label>
<input
id="draft-meta-description"
value={editor.metaDescription}
onChange={(event) =>
setEditor((current) => ({ ...current, metaDescription: event.target.value }))
}
/>
<label htmlFor="draft-body">Body markdown</label>
<textarea
id="draft-body"
className="codeArea"
value={editor.bodyMarkdown}
onChange={(event) =>
setEditor((current) => ({ ...current, bodyMarkdown: event.target.value }))
}
/>
<div className="formActions">
<button type="submit" disabled={isBusy || !selectedDraft}>
Save as next version
</button>
</div>
</form>
<h3>Preview</h3>
<div className="previewPane">
{previewBlocks.map((block, index) => {
if (block.type === "heading") {
if (block.level <= 2) {
return <h4 key={`heading-${index}`}>{block.text}</h4>;
}
return <h5 key={`heading-${index}`}>{block.text}</h5>;
}
if (block.type === "paragraph") {
return (
<p key={`paragraph-${index}`}>
{block.text}
{block.links.map((link) => (
<span key={`${link.href}-${link.label}`}> • <a href={link.href}>{link.label}</a></span>
))}
</p>
);
}
if (block.type === "table") {
return (
<table className="table" key={`table-${index}`}>
<thead>
<tr>
{block.headers.map((header, headerIndex) => (
<th key={`${header}-${headerIndex}`}>{header}</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, rowIndex) => (
<tr key={`row-${rowIndex}`}>
{row.map((cell, cellIndex) => (
<td key={`cell-${rowIndex}-${cellIndex}`}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
);
}
if (block.type === "image") {
return (
<figure key={`image-${index}`} className="previewImage">
<img src={block.src} alt={block.alt} />
<figcaption>{block.alt || block.src}</figcaption>
</figure>
);
}
return (
<section key={`faq-${index}`}>
<h4>FAQ</h4>
<ul className="timeline">
{block.items.map((item) => (
<li key={item.question}>
<strong>{item.question}</strong>
<div>{item.answer}</div>
</li>
))}
</ul>
</section>
);
})}
{previewBlocks.length === 0 ? <p>No preview content.</p> : null}
</div>
</section>
);
}
function createEditorState(draft: DraftSummary | null): DraftEditorState {
if (!draft) {
return {
title: "",
metaTitle: "",
metaDescription: "",
bodyMarkdown: "",
};
}
return {
title: draft.title,
metaTitle: draft.meta_title ?? "",
metaDescription: draft.meta_description ?? "",
bodyMarkdown: draft.body_markdown ?? "",
};
}
@@ -5,7 +5,8 @@ import {
buildDetailSummary,
} from "@/features/article-detail/model";
import { ArticleDetailShell } from "@/features/article-detail/ui";
import { fetchArticleDetail } from "@/shared/pipeline-api";
import { DraftEditorPanel } from "@/features/article-draft/ui";
import { fetchArticleDetail, fetchDrafts } from "@/shared/pipeline-api";
import { ApiError } from "@/shared/pipeline-api";
type ArticleDetailPageProps = {
@@ -16,7 +17,10 @@ export default async function ArticleDetailPage({
articleId,
}: ArticleDetailPageProps) {
try {
const detail = await fetchArticleDetail(articleId);
const [detail, draftsResponse] = await Promise.all([
fetchArticleDetail(articleId),
fetchDrafts(articleId),
]);
const summary = buildDetailSummary(detail);
return (
@@ -31,6 +35,7 @@ export default async function ArticleDetailPage({
<RoleNavigation role="EDITOR" />
<section className="panel">
<ArticleDetailShell summary={summary} />
<DraftEditorPanel articleId={articleId} initialDrafts={draftsResponse.drafts ?? []} />
</section>
</main>
);
+33
View File
@@ -10,6 +10,10 @@ import type {
BoundaryQuestionResponse,
BoundaryQuestionUpdateRequest,
CurrentUserResponse,
DraftAssembleResponse,
DraftListResponse,
DraftResponse,
DraftUpdateRequest,
EvidenceMatrixResponse,
EvidenceResponse,
EvidenceCreateRequest,
@@ -426,3 +430,32 @@ export function removeEvidence(
DEMO_EDITOR_EMAIL,
);
}
export function assembleDraft(articleId: string): Promise<DraftAssembleResponse> {
return apiPost<unknown, DraftAssembleResponse>(
`/api/articles/${articleId}/draft/assemble`,
undefined,
);
}
export function fetchDrafts(articleId: string): Promise<DraftListResponse> {
return apiGet<DraftListResponse>(`/api/articles/${articleId}/drafts`);
}
export function fetchDraft(
articleId: string,
draftId: string,
): Promise<DraftResponse> {
return apiGet<DraftResponse>(`/api/articles/${articleId}/drafts/${draftId}`);
}
export function updateDraft(
articleId: string,
draftId: string,
request: DraftUpdateRequest,
): Promise<DraftResponse> {
return apiPatch<DraftUpdateRequest, DraftResponse>(
`/api/articles/${articleId}/drafts/${draftId}`,
request,
);
}
@@ -0,0 +1,104 @@
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-detail/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 { buildProductionArtifactRows } = moduleExports;
const rows = buildProductionArtifactRows([
{
id: "job-section-1",
article_id: "a1",
parent_job_id: null,
attempt: 1,
job_type: "SECTION_SCAFFOLD",
agent_profile: "fake-section-scaffold",
status: "SUCCEEDED",
workspace_path: null,
input_files: [],
output_files: [],
payload: {
artifact_key: "section:1",
artifact_label: "Context and scope",
used_evidence_ids: ["e1", "e2"],
unsupported_claims: [],
},
error_category: null,
error_message: null,
stdout: "",
stderr: "",
exit_code: 0,
duration_ms: 3,
queued_at: "2026-05-21T00:00:00Z",
started_at: "2026-05-21T00:00:01Z",
finished_at: "2026-05-21T00:00:02Z",
},
{
id: "job-section-2",
article_id: "a1",
parent_job_id: null,
attempt: 1,
job_type: "SECTION_SCAFFOLD",
agent_profile: "fake-section-scaffold",
status: "FAILED",
workspace_path: null,
input_files: [],
output_files: [],
payload: {
artifact_key: "section:2",
artifact_label: "Implementation workflow",
used_evidence_ids: ["e2"],
unsupported_claims: [{ claim_text: "Unverified claim" }],
},
error_category: "UNSUPPORTED_CLAIMS_FOUND",
error_message: "Unsupported claims introduced during scaffolding: 1",
stdout: "",
stderr: "",
exit_code: 0,
duration_ms: 3,
queued_at: "2026-05-21T00:00:00Z",
started_at: "2026-05-21T00:00:01Z",
finished_at: "2026-05-21T00:00:02Z",
},
{
id: "job-non-artifact",
article_id: "a1",
parent_job_id: null,
attempt: 1,
job_type: "TEST_CODEX",
agent_profile: "fake-codex",
status: "SUCCEEDED",
workspace_path: null,
input_files: [],
output_files: [],
payload: {},
error_category: null,
error_message: null,
stdout: "",
stderr: "",
exit_code: 0,
duration_ms: 0,
queued_at: "2026-05-21T00:00:00Z",
started_at: "2026-05-21T00:00:01Z",
finished_at: "2026-05-21T00:00:02Z",
},
]);
assert.equal(rows.length, 2);
assert.equal(rows[0].artifactLabel, "Context and scope");
assert.equal(rows[0].status, "SUCCEEDED");
assert.deepEqual(rows[0].usedEvidenceIds, ["e1", "e2"]);
assert.equal(rows[1].status, "FAILED");
assert.deepEqual(rows[1].unsupportedClaims, ["Unverified claim"]);
@@ -0,0 +1,75 @@
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-draft/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 { compareDraftVersions, parseDraftPreview, selectDraftById } = moduleExports;
const baseDraft = {
id: "11111111-1111-1111-1111-111111111111",
article_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
version: 1,
title: "Draft One",
slug: "draft-one",
meta_title: "Meta One",
meta_description: "Description One",
body_object_key: "articles/a/drafts/v1.md",
body_markdown: [
"# Draft One",
"",
"## Intro",
"See [guide](https://example.com/guide).",
"",
"| A | B |",
"| --- | --- |",
"| 1 | 2 |",
"",
"![Mock](placeholder://mock/1)",
].join("\n"),
faq_items: [{ question: "What is this?", answer: "Preview draft." }],
visual_placeholders: ["![Mock](placeholder://mock/1)"],
evidence_references: ["evidence-id-1"],
unsupported_claim_warnings: [],
based_on_draft_id: null,
status: "DRAFT_ASSEMBLED",
created_at: "2026-05-21T00:00:00Z",
updated_at: "2026-05-21T00:00:00Z",
};
const secondDraft = {
...baseDraft,
id: "22222222-2222-2222-2222-222222222222",
version: 2,
title: "Draft Two",
meta_title: "Meta Two",
body_markdown: `${baseDraft.body_markdown}\n\nRevision note.`,
based_on_draft_id: baseDraft.id,
};
const previewBlocks = parseDraftPreview(baseDraft.body_markdown, baseDraft.faq_items);
assert.ok(previewBlocks.some((block) => block.type === "heading" && block.text === "Draft One"));
assert.ok(previewBlocks.some((block) => block.type === "table"));
assert.ok(previewBlocks.some((block) => block.type === "image" && block.src === "placeholder://mock/1"));
assert.ok(previewBlocks.some((block) => block.type === "paragraph" && block.links.length === 1));
assert.ok(previewBlocks.some((block) => block.type === "faq" && block.items.length === 1));
const selected = selectDraftById([secondDraft, baseDraft], baseDraft.id);
assert.equal(selected.id, baseDraft.id);
const comparison = compareDraftVersions(secondDraft, baseDraft);
assert.equal(comparison.titleChanged, true);
assert.equal(comparison.metaTitleChanged, true);
assert.equal(comparison.bodyChanged, true);
assert.ok(comparison.changedLineCount >= 1);
+500
View File
@@ -238,6 +238,11 @@
"default": null,
"title": "Parent Job Id"
},
"payload": {
"additionalProperties": true,
"title": "Payload",
"type": "object"
},
"queued_at": {
"format": "date-time",
"title": "Queued At",
@@ -999,6 +1004,67 @@
"title": "CurrentUserResponse",
"type": "object"
},
"DraftAssembleResponse": {
"additionalProperties": false,
"properties": {
"draft": {
"$ref": "#/components/schemas/DraftSummary"
}
},
"required": [
"draft"
],
"title": "DraftAssembleResponse",
"type": "object"
},
"DraftFaqItem": {
"additionalProperties": false,
"properties": {
"answer": {
"minLength": 1,
"title": "Answer",
"type": "string"
},
"question": {
"minLength": 1,
"title": "Question",
"type": "string"
}
},
"required": [
"question",
"answer"
],
"title": "DraftFaqItem",
"type": "object"
},
"DraftListResponse": {
"additionalProperties": false,
"properties": {
"drafts": {
"items": {
"$ref": "#/components/schemas/DraftSummary"
},
"title": "Drafts",
"type": "array"
}
},
"title": "DraftListResponse",
"type": "object"
},
"DraftResponse": {
"additionalProperties": false,
"properties": {
"draft": {
"$ref": "#/components/schemas/DraftSummary"
}
},
"required": [
"draft"
],
"title": "DraftResponse",
"type": "object"
},
"DraftSummary": {
"additionalProperties": false,
"properties": {
@@ -1007,6 +1073,24 @@
"title": "Article Id",
"type": "string"
},
"based_on_draft_id": {
"anyOf": [
{
"format": "uuid",
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Based On Draft Id"
},
"body_markdown": {
"default": "",
"title": "Body Markdown",
"type": "string"
},
"body_object_key": {
"anyOf": [
{
@@ -1019,6 +1103,26 @@
"default": null,
"title": "Body Object Key"
},
"created_at": {
"format": "date-time",
"title": "Created At",
"type": "string"
},
"evidence_references": {
"items": {
"format": "uuid",
"type": "string"
},
"title": "Evidence References",
"type": "array"
},
"faq_items": {
"items": {
"$ref": "#/components/schemas/DraftFaqItem"
},
"title": "Faq Items",
"type": "array"
},
"id": {
"format": "uuid",
"title": "Id",
@@ -1061,6 +1165,13 @@
"title": "Title",
"type": "string"
},
"unsupported_claim_warnings": {
"items": {
"type": "string"
},
"title": "Unsupported Claim Warnings",
"type": "array"
},
"updated_at": {
"format": "date-time",
"title": "Updated At",
@@ -1070,6 +1181,13 @@
"minimum": 1,
"title": "Version",
"type": "integer"
},
"visual_placeholders": {
"items": {
"type": "string"
},
"title": "Visual Placeholders",
"type": "array"
}
},
"required": [
@@ -1079,11 +1197,129 @@
"title",
"slug",
"status",
"created_at",
"updated_at"
],
"title": "DraftSummary",
"type": "object"
},
"DraftUpdateRequest": {
"additionalProperties": false,
"properties": {
"body_markdown": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Body Markdown"
},
"evidence_references": {
"anyOf": [
{
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Evidence References"
},
"faq_items": {
"anyOf": [
{
"items": {
"$ref": "#/components/schemas/DraftFaqItem"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Faq Items"
},
"meta_description": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Meta Description"
},
"meta_title": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Meta Title"
},
"title": {
"anyOf": [
{
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Title"
},
"unsupported_claim_warnings": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Unsupported Claim Warnings"
},
"visual_placeholders": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Visual Placeholders"
}
},
"title": "DraftUpdateRequest",
"type": "object"
},
"EvidenceCreateRequest": {
"additionalProperties": false,
"properties": {
@@ -3668,6 +3904,65 @@
]
}
},
"/api/articles/{article_id}/draft/assemble": {
"post": {
"operationId": "post_assemble_draft_api_articles__article_id__draft_assemble_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/DraftAssembleResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Post Assemble Draft",
"tags": [
"drafts"
]
}
},
"/api/articles/{article_id}/draft/start": {
"post": {
"operationId": "post_start_draft_api_articles__article_id__draft_start_post",
@@ -3727,6 +4022,211 @@
]
}
},
"/api/articles/{article_id}/drafts": {
"get": {
"operationId": "get_article_drafts_api_articles__article_id__drafts_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/DraftListResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Get Article Drafts",
"tags": [
"drafts"
]
}
},
"/api/articles/{article_id}/drafts/{draft_id}": {
"get": {
"operationId": "get_article_draft_api_articles__article_id__drafts__draft_id__get",
"parameters": [
{
"in": "path",
"name": "article_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Article Id",
"type": "string"
}
},
{
"in": "path",
"name": "draft_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Draft 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/DraftResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Get Article Draft",
"tags": [
"drafts"
]
},
"patch": {
"operationId": "patch_article_draft_api_articles__article_id__drafts__draft_id__patch",
"parameters": [
{
"in": "path",
"name": "article_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Article Id",
"type": "string"
}
},
{
"in": "path",
"name": "draft_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Draft 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/DraftUpdateRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DraftResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Patch Article Draft",
"tags": [
"drafts"
]
}
},
"/api/articles/{article_id}/evidence": {
"get": {
"operationId": "get_article_evidence_api_articles__article_id__evidence_get",
+36
View File
@@ -36,6 +36,7 @@ export type AgentJobSummary = {
job_type: AgentJobType;
output_files?: RunnerFileRef[];
parent_job_id?: string | null;
payload?: Record<string, unknown>;
queued_at: string;
started_at?: string | null;
status: AgentJobStatus;
@@ -167,17 +168,52 @@ export type CurrentUserResponse = {
user: CurrentUser;
};
export type DraftAssembleResponse = {
draft: DraftSummary;
};
export type DraftFaqItem = {
answer: string;
question: string;
};
export type DraftListResponse = {
drafts?: DraftSummary[];
};
export type DraftResponse = {
draft: DraftSummary;
};
export type DraftSummary = {
article_id: string;
based_on_draft_id?: string | null;
body_markdown?: string;
body_object_key?: string | null;
created_at: string;
evidence_references?: string[];
faq_items?: DraftFaqItem[];
id: string;
meta_description?: string | null;
meta_title?: string | null;
slug: string;
status: ArticleWorkflowStatus;
title: string;
unsupported_claim_warnings?: string[];
updated_at: string;
version: number;
visual_placeholders?: string[];
};
export type DraftUpdateRequest = {
body_markdown?: string | null;
evidence_references?: string[] | null;
faq_items?: DraftFaqItem[] | null;
meta_description?: string | null;
meta_title?: string | null;
title?: string | null;
unsupported_claim_warnings?: string[] | null;
visual_placeholders?: string[] | null;
};
export type EvidenceCreateRequest = {
+31 -14
View File
@@ -32,14 +32,14 @@ Development description: Implement parallel section scaffolding, FAQ, SEO brief,
## Acceptance Criteria
- [ ] TDD pre-requirement: before implementation, write one failing behavior test that starts production and creates one job per plan section; proceed one artifact behavior at a time and record evidence in `Result`.
- [ ] Production cannot start before evidence matrix is ready.
- [ ] One section scaffold job is created per approved plan section.
- [ ] Jobs run independently and can fail independently.
- [ ] Retrying one failed section does not rerun successful sections.
- [ ] Each scaffold lists used evidence IDs.
- [ ] Unsupported claims introduced during scaffolding are captured and shown.
- [ ] UI displays per-artifact job state.
- [x] TDD pre-requirement: before implementation, write one failing behavior test that starts production and creates one job per plan section; proceed one artifact behavior at a time and record evidence in `Result`.
- [x] Production cannot start before evidence matrix is ready.
- [x] One section scaffold job is created per approved plan section.
- [x] Jobs run independently and can fail independently.
- [x] Retrying one failed section does not rerun successful sections.
- [x] Each scaffold lists used evidence IDs.
- [x] Unsupported claims introduced during scaffolding are captured and shown.
- [x] UI displays per-artifact job state.
## Verification
@@ -49,9 +49,26 @@ Development description: Implement parallel section scaffolding, FAQ, SEO brief,
## 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: Completed (TDD RED -> GREEN).
- TDD plan:
1. Keep the existing RED integration behavior for `/api/articles/{article_id}/draft/start` (one `SECTION_SCAFFOLD` job per approved section).
2. Implement readiness gating and parallel section job orchestration in `start_draft`.
3. Add integration tests for independent failures and retry lineage.
4. Add frontend model/UI projection for per-artifact job state.
- Red evidence:
- Existing failing test: `test_start_production_creates_one_section_job_per_approved_plan_section`.
- Failure before implementation: `AssertionError: 4 != 0 : Expected one SECTION_SCAFFOLD job per approved plan section`.
- Green evidence:
- `start_draft` now requires `EVIDENCE_MATRIX_READY`, transitions article to `PARALLEL_PRODUCTION_RUNNING`, and creates one `SECTION_SCAFFOLD` job per approved plan section.
- Section jobs persist scaffold payload (`artifact_key`, `artifact_label`, `used_evidence_ids`, `unsupported_claims`, `draft_markdown`).
- Completing a section job with non-empty `unsupported_claims` forces job status to `FAILED` with `UNSUPPORTED_CLAIMS_FOUND`.
- Retry creates a child attempt only for the failed section job (successful siblings are untouched).
- Article detail API now includes article-specific `agent_jobs` with payload for UI rendering.
- Refactor notes:
- Added `payload` JSON field to `agent_jobs` contract + storage for deterministic scaffold metadata projection.
- Added repository helper `list_for_article` for article-level job timeline queries.
- Verification output:
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_parallel_production_public_api.py` -> `Ran 6 tests ... OK`
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_evidence_matrix_public_api.py` -> `Ran 2 tests ... OK`
- `node apps/frontend/tests/article_detail.model.test.mjs` -> `pass`
- `node apps/frontend/tests/agent_jobs.model.test.mjs` -> `pass`
+35 -13
View File
@@ -35,13 +35,13 @@ Development description: Assemble section scaffolds into a canonical Markdown/MD
## Acceptance Criteria
- [ ] TDD pre-requirement: before implementation, write one failing behavior test that assembles a draft from successful section scaffolds through the public API; proceed one draft behavior at a time and record evidence in `Result`.
- [ ] Draft assembly includes all planned sections in order.
- [ ] Draft includes metadata, FAQ, visual placeholders, evidence references, and unsupported claim warnings.
- [ ] Draft version 1 is immutable after approval-relevant edits; edits create a new version.
- [ ] Generic Markdown/MDX preview renders headings, links, tables, images/placeholders, and FAQ.
- [ ] Editor can compare or select draft versions.
- [ ] Draft assembly fails clearly if required section scaffolds are missing.
- [x] TDD pre-requirement: before implementation, write one failing behavior test that assembles a draft from successful section scaffolds through the public API; proceed one draft behavior at a time and record evidence in `Result`.
- [x] Draft assembly includes all planned sections in order.
- [x] Draft includes metadata, FAQ, visual placeholders, evidence references, and unsupported claim warnings.
- [x] Draft version 1 is immutable after approval-relevant edits; edits create a new version.
- [x] Generic Markdown/MDX preview renders headings, links, tables, images/placeholders, and FAQ.
- [x] Editor can compare or select draft versions.
- [x] Draft assembly fails clearly if required section scaffolds are missing.
## Verification
@@ -51,9 +51,31 @@ Development description: Assemble section scaffolds into a canonical Markdown/MD
## 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: Completed. Pre-requirement (RED) and implementation (GREEN) finished.
- TDD plan:
1. Keep the original RED pre-requirement behavior test for `POST /api/articles/{article_id}/draft/assemble`.
2. Extend backend integration coverage for draft assembly payload completeness, immutable versioning, missing scaffold conflict, unsupported warning propagation, and not-found paths.
3. Implement backend drafts application layer and public routes for assemble/list/get/patch.
4. Extend shared API contracts (OpenAPI + generated TS types).
5. Implement frontend draft editor with version selector, compare summary, and generic markdown preview parser/renderer.
6. Add frontend model tests for preview parsing and version comparison.
- Red evidence:
- Pre-requirement test `apps/backend/tests/integration/test_draft_assembly_public_api.py::DraftAssemblyPublicApiTest::test_assemble_draft_from_successful_section_scaffolds` initially failed before implementation.
- Observed RED error before implementation: `AssertionError: 201 != 404 : {"detail":"Not Found"}`.
- Green evidence:
- `POST /api/articles/{article_id}/draft/assemble` implemented and returns `201` with canonical draft payload.
- `GET /api/articles/{article_id}/drafts`, `GET /api/articles/{article_id}/drafts/{draft_id}`, and `PATCH /api/articles/{article_id}/drafts/{draft_id}` implemented and covered.
- Immutable versioning verified: patch creates `v2+`, base draft remains unchanged.
- Missing required successful section scaffolds returns clear `409`.
- Frontend generic preview parser renders headings, links, tables, images/placeholders, and FAQ blocks.
- Frontend editor supports version selection and compare summary.
- Refactor notes:
- Added dedicated backend drafts service (`application/drafts.py`) and drafts route module to isolate draft logic from evidence/plan flows.
- Extended `article_drafts` persistence model with canonical body and metadata lists (`faq_items`, placeholders, evidence refs, warnings, based_on_draft_id`) for explicit version snapshots.
- Verification output:
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_draft_assembly_public_api.py` -> OK (5 tests)
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_parallel_production_public_api.py` -> OK (6 tests)
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_evidence_matrix_public_api.py` -> OK (2 tests)
- `node apps/frontend/tests/draft_editor.model.test.mjs` -> OK
- `node apps/frontend/tests/article_detail.model.test.mjs` -> OK
- `pnpm --dir apps/frontend typecheck` -> OK