Task 010 implement research manifest flow
This commit is contained in:
@@ -60,12 +60,14 @@ def get_article_detail(
|
|||||||
workflow_events = repository.articles.list_workflow_events(article_id)
|
workflow_events = repository.articles.list_workflow_events(article_id)
|
||||||
boundary_questions = repository.boundary_questions.list_for_article(article_id)
|
boundary_questions = repository.boundary_questions.list_for_article(article_id)
|
||||||
plans = repository.article_plans.list_for_article(article_id)
|
plans = repository.article_plans.list_for_article(article_id)
|
||||||
|
research_manifests = repository.research_manifests.list_for_article(article_id)
|
||||||
return ArticleDetailResponse(
|
return ArticleDetailResponse(
|
||||||
article=article,
|
article=article,
|
||||||
target_site=target_site,
|
target_site=target_site,
|
||||||
workflow_events=workflow_events,
|
workflow_events=workflow_events,
|
||||||
boundary_questions=boundary_questions,
|
boundary_questions=boundary_questions,
|
||||||
plan=plans[-1] if plans else None,
|
plan=plans[-1] if plans else None,
|
||||||
|
research_manifests=research_manifests,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from src.domain.contracts import (
|
||||||
|
AgentJobStatus,
|
||||||
|
AgentJobType,
|
||||||
|
ArticleWorkflowStatus,
|
||||||
|
PlanReviewStatus,
|
||||||
|
ResearchListResponse,
|
||||||
|
ResearchStartResponse,
|
||||||
|
)
|
||||||
|
from src.infrastructure.object_storage import ObjectStorageClient
|
||||||
|
|
||||||
|
|
||||||
|
def start_research_run(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
object_storage: ObjectStorageClient,
|
||||||
|
) -> ResearchStartResponse:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
approved_plan = _approved_plan(repository, article_id)
|
||||||
|
if approved_plan is None:
|
||||||
|
raise PermissionError("Plan must be approved before research can start")
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
if article.status != ArticleWorkflowStatus.RESEARCH_RUNNING:
|
||||||
|
article = repository.articles.update_status(
|
||||||
|
article_id=article_id,
|
||||||
|
status=ArticleWorkflowStatus.RESEARCH_RUNNING,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
job = repository.agent_jobs.create(
|
||||||
|
article_id=article_id,
|
||||||
|
parent_job_id=None,
|
||||||
|
attempt=1,
|
||||||
|
job_type=AgentJobType.RESEARCH,
|
||||||
|
agent_profile="fake-research-fetch",
|
||||||
|
status=AgentJobStatus.SUCCEEDED,
|
||||||
|
input_files=[{"path": "inputs/approved-plan.json", "content_hash": None}],
|
||||||
|
queued_at=now,
|
||||||
|
)
|
||||||
|
run_id = uuid4()
|
||||||
|
s3_prefix = f"research/{article_id}/{run_id}"
|
||||||
|
artifacts = _build_fake_research_artifacts(
|
||||||
|
article=article,
|
||||||
|
plan=approved_plan,
|
||||||
|
s3_prefix=s3_prefix,
|
||||||
|
object_storage=object_storage,
|
||||||
|
retrieved_at=now,
|
||||||
|
)
|
||||||
|
manifest = repository.research_manifests.create(
|
||||||
|
article_id=article_id,
|
||||||
|
agent_job_id=job.id,
|
||||||
|
s3_prefix=s3_prefix,
|
||||||
|
artifacts=artifacts,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
job = repository.agent_jobs.complete(
|
||||||
|
job_id=job.id,
|
||||||
|
status=AgentJobStatus.SUCCEEDED,
|
||||||
|
workspace_path=None,
|
||||||
|
output_files=[{"path": artifact["object_key"]} for artifact in artifacts],
|
||||||
|
error_category=None,
|
||||||
|
error_message=None,
|
||||||
|
stdout="fake research fetch completed\n",
|
||||||
|
stderr="",
|
||||||
|
exit_code=0,
|
||||||
|
duration_ms=0,
|
||||||
|
finished_at=now,
|
||||||
|
)
|
||||||
|
return ResearchStartResponse(article=article, job=job, manifest=manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def list_research_runs(repository: object, *, article_id: UUID) -> ResearchListResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
return ResearchListResponse(
|
||||||
|
manifests=repository.research_manifests.list_for_article(article_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 _build_fake_research_artifacts(
|
||||||
|
*,
|
||||||
|
article: object,
|
||||||
|
plan: object,
|
||||||
|
s3_prefix: str,
|
||||||
|
object_storage: ObjectStorageClient,
|
||||||
|
retrieved_at: datetime,
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
sources = [
|
||||||
|
{
|
||||||
|
"url": "https://example.com/research/content-governance",
|
||||||
|
"title": "Content governance implementation guide",
|
||||||
|
"source_type": "primary_reference",
|
||||||
|
"summary": "Governance programs need explicit review gates and source evidence.",
|
||||||
|
"section": "A source-section snapshot about governance workflow controls.",
|
||||||
|
"ip": "93.184.216.34",
|
||||||
|
"whois": "Example Domain Registry",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://example.org/research/evidence-standards",
|
||||||
|
"title": "Evidence standards for editorial claims",
|
||||||
|
"source_type": "standards_reference",
|
||||||
|
"summary": "Editorial claims should be linked to durable source artifacts.",
|
||||||
|
"section": "A source-section snapshot about evidence standards and claim review.",
|
||||||
|
"ip": "93.184.216.34",
|
||||||
|
"whois": "Example Organization Registry",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
artifacts: list[dict[str, object]] = []
|
||||||
|
for index, source in enumerate(sources, start=1):
|
||||||
|
parsed = urlparse(str(source["url"]))
|
||||||
|
base_key = f"{s3_prefix}/source-{index}"
|
||||||
|
content_key = f"{base_key}/section.json"
|
||||||
|
metadata_key = f"{base_key}/metadata.json"
|
||||||
|
payload = {
|
||||||
|
"article_id": str(article.id),
|
||||||
|
"plan_id": str(plan.id),
|
||||||
|
"plan_version": plan.version,
|
||||||
|
"source_url": source["url"],
|
||||||
|
"section_snapshot": source["section"],
|
||||||
|
"retrieved_at": retrieved_at.isoformat(),
|
||||||
|
}
|
||||||
|
metadata = {
|
||||||
|
"source_title": source["title"],
|
||||||
|
"domain": parsed.netloc,
|
||||||
|
"source_type": source["source_type"],
|
||||||
|
"summary": source["summary"],
|
||||||
|
"retrieved_at": retrieved_at.isoformat(),
|
||||||
|
"artifact_link": content_key,
|
||||||
|
"metadata_object_key": metadata_key,
|
||||||
|
"search_path": [
|
||||||
|
article.primary_keyword or article.working_title or "article research",
|
||||||
|
str(source["title"]),
|
||||||
|
],
|
||||||
|
"selection_criteria": [
|
||||||
|
"Matches approved plan evidence needs",
|
||||||
|
"Deterministic fake fixture for demo and tests",
|
||||||
|
],
|
||||||
|
"head_metadata": {
|
||||||
|
"title": source["title"],
|
||||||
|
"description": source["summary"],
|
||||||
|
"canonical": source["url"],
|
||||||
|
},
|
||||||
|
"server_ip_address": source["ip"],
|
||||||
|
"whois_owner": source["whois"],
|
||||||
|
}
|
||||||
|
content = json.dumps(payload, sort_keys=True, indent=2)
|
||||||
|
metadata_content = json.dumps(metadata, sort_keys=True, indent=2)
|
||||||
|
object_storage.put_text(object_key=content_key, content=content)
|
||||||
|
object_storage.put_text(object_key=metadata_key, content=metadata_content)
|
||||||
|
artifacts.append(
|
||||||
|
{
|
||||||
|
"artifact_type": "source_section_snapshot",
|
||||||
|
"source_url": source["url"],
|
||||||
|
"object_key": content_key,
|
||||||
|
"content_hash": hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
||||||
|
"metadata": metadata,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return artifacts
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
@@ -47,6 +47,8 @@ from .models import (
|
|||||||
PublishingRules,
|
PublishingRules,
|
||||||
ResearchArtifactManifestSummary,
|
ResearchArtifactManifestSummary,
|
||||||
ResearchArtifactSummary,
|
ResearchArtifactSummary,
|
||||||
|
ResearchListResponse,
|
||||||
|
ResearchStartResponse,
|
||||||
ReviewActionResponse,
|
ReviewActionResponse,
|
||||||
ReviewSummary,
|
ReviewSummary,
|
||||||
RunnerFileRef,
|
RunnerFileRef,
|
||||||
@@ -111,6 +113,8 @@ __all__ = [
|
|||||||
"PublishingStatus",
|
"PublishingStatus",
|
||||||
"ResearchArtifactManifestSummary",
|
"ResearchArtifactManifestSummary",
|
||||||
"ResearchArtifactSummary",
|
"ResearchArtifactSummary",
|
||||||
|
"ResearchListResponse",
|
||||||
|
"ResearchStartResponse",
|
||||||
"ReviewActionResponse",
|
"ReviewActionResponse",
|
||||||
"ReviewStatus",
|
"ReviewStatus",
|
||||||
"ReviewSummary",
|
"ReviewSummary",
|
||||||
|
|||||||
@@ -299,6 +299,17 @@ class ResearchArtifactManifestSummary(ContractModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchStartResponse(ContractModel):
|
||||||
|
article: ArticleSummary
|
||||||
|
job: AgentJobSummary
|
||||||
|
manifest: ResearchArtifactManifestSummary
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchListResponse(ContractModel):
|
||||||
|
manifests: list[ResearchArtifactManifestSummary] = Field(default_factory=list)
|
||||||
|
evidence: list["EvidenceSummary"] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class EvidenceSummary(ContractModel):
|
class EvidenceSummary(ContractModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
article_id: UUID
|
article_id: UUID
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ from .models import (
|
|||||||
PublishingRules,
|
PublishingRules,
|
||||||
ResearchArtifactManifestSummary,
|
ResearchArtifactManifestSummary,
|
||||||
ResearchArtifactSummary,
|
ResearchArtifactSummary,
|
||||||
|
ResearchListResponse,
|
||||||
|
ResearchStartResponse,
|
||||||
ReviewActionResponse,
|
ReviewActionResponse,
|
||||||
ReviewSummary,
|
ReviewSummary,
|
||||||
RunnerFileRef,
|
RunnerFileRef,
|
||||||
@@ -117,6 +119,8 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
|||||||
PlanListResponse,
|
PlanListResponse,
|
||||||
ResearchArtifactSummary,
|
ResearchArtifactSummary,
|
||||||
ResearchArtifactManifestSummary,
|
ResearchArtifactManifestSummary,
|
||||||
|
ResearchStartResponse,
|
||||||
|
ResearchListResponse,
|
||||||
EvidenceSummary,
|
EvidenceSummary,
|
||||||
ClaimSummary,
|
ClaimSummary,
|
||||||
DraftSummary,
|
DraftSummary,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectStorageClient:
|
||||||
|
def put_text(self, *, object_key: str, content: str) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class LocalObjectStorageClient(ObjectStorageClient):
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
self.root = root
|
||||||
|
|
||||||
|
def put_text(self, *, object_key: str, content: str) -> str:
|
||||||
|
path = self.root / object_key
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
return f"file://{path}"
|
||||||
|
|
||||||
|
|
||||||
|
class S3ObjectStorageClient(ObjectStorageClient):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
import boto3
|
||||||
|
from botocore.config import Config
|
||||||
|
|
||||||
|
self.bucket = _required_env("OBJECT_STORAGE_BUCKET")
|
||||||
|
self.client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=_required_env("OBJECT_STORAGE_ENDPOINT"),
|
||||||
|
aws_access_key_id=_required_env("OBJECT_STORAGE_ACCESS_KEY_ID"),
|
||||||
|
aws_secret_access_key=_required_env("OBJECT_STORAGE_SECRET_ACCESS_KEY"),
|
||||||
|
region_name=os.environ.get("OBJECT_STORAGE_REGION", "us-east-1"),
|
||||||
|
config=Config(signature_version="s3v4"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def put_text(self, *, object_key: str, content: str) -> str:
|
||||||
|
self.client.put_object(
|
||||||
|
Bucket=self.bucket,
|
||||||
|
Key=object_key,
|
||||||
|
Body=content.encode("utf-8"),
|
||||||
|
ContentType="application/json; charset=utf-8",
|
||||||
|
)
|
||||||
|
return f"s3://{self.bucket}/{object_key}"
|
||||||
|
|
||||||
|
|
||||||
|
def open_object_storage_client() -> ObjectStorageClient:
|
||||||
|
local_root = os.environ.get("OBJECT_STORAGE_LOCAL_ROOT")
|
||||||
|
if local_root:
|
||||||
|
return LocalObjectStorageClient(Path(local_root))
|
||||||
|
|
||||||
|
if os.environ.get("OBJECT_STORAGE_ENDPOINT"):
|
||||||
|
return S3ObjectStorageClient()
|
||||||
|
|
||||||
|
return LocalObjectStorageClient(Path("/tmp/pipeline-object-storage"))
|
||||||
|
|
||||||
|
|
||||||
|
def _required_env(name: str) -> str:
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if not value:
|
||||||
|
raise RuntimeError(f"Missing required environment variable: {name}")
|
||||||
|
return value
|
||||||
@@ -20,6 +20,8 @@ from src.domain.contracts import (
|
|||||||
PlanSummary,
|
PlanSummary,
|
||||||
PublishingRules,
|
PublishingRules,
|
||||||
PublishingStatus,
|
PublishingStatus,
|
||||||
|
ResearchArtifactManifestSummary,
|
||||||
|
ResearchArtifactSummary,
|
||||||
ArticleWorkflowStatus,
|
ArticleWorkflowStatus,
|
||||||
WorkflowEventSummary,
|
WorkflowEventSummary,
|
||||||
Role,
|
Role,
|
||||||
@@ -46,6 +48,7 @@ class BackendRepository:
|
|||||||
self.articles = ArticlesRepository(self)
|
self.articles = ArticlesRepository(self)
|
||||||
self.boundary_questions = BoundaryQuestionsRepository(self)
|
self.boundary_questions = BoundaryQuestionsRepository(self)
|
||||||
self.article_plans = ArticlePlansRepository(self)
|
self.article_plans = ArticlePlansRepository(self)
|
||||||
|
self.research_manifests = ResearchManifestsRepository(self)
|
||||||
self.agent_jobs = AgentJobsRepository(self)
|
self.agent_jobs = AgentJobsRepository(self)
|
||||||
self.script_config_versions = ScriptConfigVersionsRepository(self)
|
self.script_config_versions = ScriptConfigVersionsRepository(self)
|
||||||
self.script_config_version_events = ScriptConfigVersionAuditEventsRepository(self)
|
self.script_config_version_events = ScriptConfigVersionAuditEventsRepository(self)
|
||||||
@@ -1005,6 +1008,121 @@ class ArticlePlansRepository:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchManifestsRepository:
|
||||||
|
def __init__(self, repository: BackendRepository) -> None:
|
||||||
|
self._repository = repository
|
||||||
|
|
||||||
|
def create(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
agent_job_id: UUID,
|
||||||
|
s3_prefix: str,
|
||||||
|
artifacts: list[JsonObject],
|
||||||
|
created_at: datetime,
|
||||||
|
) -> ResearchArtifactManifestSummary:
|
||||||
|
manifest_id = uuid4()
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
json_cast = self._repository.json_cast()
|
||||||
|
source_urls = [artifact["source_url"] for artifact in artifacts]
|
||||||
|
object_keys = [artifact["object_key"] for artifact in artifacts]
|
||||||
|
content_hashes = [artifact["content_hash"] for artifact in artifacts]
|
||||||
|
artifact_types = [artifact["artifact_type"] for artifact in artifacts]
|
||||||
|
metadata_references = [
|
||||||
|
artifact.get("metadata", {}).get("metadata_object_key")
|
||||||
|
for artifact in artifacts
|
||||||
|
]
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO research_run_manifests (
|
||||||
|
id,
|
||||||
|
article_id,
|
||||||
|
agent_job_id,
|
||||||
|
s3_prefix,
|
||||||
|
source_urls,
|
||||||
|
object_keys,
|
||||||
|
content_hashes,
|
||||||
|
artifact_types,
|
||||||
|
metadata_references,
|
||||||
|
artifacts,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder}{json_cast},
|
||||||
|
{placeholder}{json_cast},
|
||||||
|
{placeholder}{json_cast},
|
||||||
|
{placeholder}{json_cast},
|
||||||
|
{placeholder}{json_cast},
|
||||||
|
{placeholder}{json_cast},
|
||||||
|
{placeholder}
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
str(manifest_id),
|
||||||
|
str(article_id),
|
||||||
|
str(agent_job_id),
|
||||||
|
s3_prefix,
|
||||||
|
_json_value(source_urls),
|
||||||
|
_json_value(object_keys),
|
||||||
|
_json_value(content_hashes),
|
||||||
|
_json_value(artifact_types),
|
||||||
|
_json_value(metadata_references),
|
||||||
|
_json_value(artifacts),
|
||||||
|
_datetime_value(created_at),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.get(manifest_id)
|
||||||
|
|
||||||
|
def list_for_article(self, article_id: UUID) -> list[ResearchArtifactManifestSummary]:
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
article_id,
|
||||||
|
agent_job_id,
|
||||||
|
s3_prefix,
|
||||||
|
artifacts,
|
||||||
|
created_at
|
||||||
|
FROM research_run_manifests
|
||||||
|
WHERE article_id = {placeholder}
|
||||||
|
ORDER BY created_at
|
||||||
|
""",
|
||||||
|
(str(article_id),),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return [_research_manifest_from_row(row) for row in rows]
|
||||||
|
|
||||||
|
def get(self, manifest_id: UUID) -> ResearchArtifactManifestSummary:
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
article_id,
|
||||||
|
agent_job_id,
|
||||||
|
s3_prefix,
|
||||||
|
artifacts,
|
||||||
|
created_at
|
||||||
|
FROM research_run_manifests
|
||||||
|
WHERE id = {placeholder}
|
||||||
|
""",
|
||||||
|
(str(manifest_id),),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise LookupError(f"Research manifest not found: {manifest_id}")
|
||||||
|
return _research_manifest_from_row(row)
|
||||||
|
|
||||||
|
|
||||||
class AgentJobsRepository:
|
class AgentJobsRepository:
|
||||||
def __init__(self, repository: BackendRepository) -> None:
|
def __init__(self, repository: BackendRepository) -> None:
|
||||||
self._repository = repository
|
self._repository = repository
|
||||||
@@ -1715,6 +1833,20 @@ def _plan_summary_from_row(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _research_manifest_from_row(row: Any) -> ResearchArtifactManifestSummary:
|
||||||
|
return ResearchArtifactManifestSummary(
|
||||||
|
id=_row_value(row, "id"),
|
||||||
|
article_id=_row_value(row, "article_id"),
|
||||||
|
agent_job_id=_row_value(row, "agent_job_id"),
|
||||||
|
s3_prefix=_row_value(row, "s3_prefix"),
|
||||||
|
artifacts=[
|
||||||
|
ResearchArtifactSummary(**artifact)
|
||||||
|
for artifact in _json_from_row(row, "artifacts")
|
||||||
|
],
|
||||||
|
created_at=_row_value(row, "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _agent_job_summary_from_row(row: Any) -> AgentJobSummary:
|
def _agent_job_summary_from_row(row: Any) -> AgentJobSummary:
|
||||||
return AgentJobSummary(
|
return AgentJobSummary(
|
||||||
id=_row_value(row, "id"),
|
id=_row_value(row, "id"),
|
||||||
|
|||||||
@@ -11,18 +11,20 @@ from src.application.plans import (
|
|||||||
get_plan,
|
get_plan,
|
||||||
list_plans,
|
list_plans,
|
||||||
request_plan_revision,
|
request_plan_revision,
|
||||||
start_research,
|
|
||||||
)
|
)
|
||||||
|
from src.application.research import list_research_runs, start_research_run
|
||||||
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
||||||
from src.domain.contracts import (
|
from src.domain.contracts import (
|
||||||
AgentJobListResponse,
|
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
PlanListResponse,
|
PlanListResponse,
|
||||||
PlanResponse,
|
PlanResponse,
|
||||||
PlanRevisionRequest,
|
PlanRevisionRequest,
|
||||||
PlanUpdateRequest,
|
PlanUpdateRequest,
|
||||||
|
ResearchListResponse,
|
||||||
|
ResearchStartResponse,
|
||||||
ReviewActionResponse,
|
ReviewActionResponse,
|
||||||
)
|
)
|
||||||
|
from src.infrastructure.object_storage import open_object_storage_client
|
||||||
from src.infrastructure.repositories import BackendRepository
|
from src.infrastructure.repositories import BackendRepository
|
||||||
from src.presentation.dependencies import get_repository, require_roles
|
from src.presentation.dependencies import get_repository, require_roles
|
||||||
|
|
||||||
@@ -143,17 +145,36 @@ def post_request_plan_revision(
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/articles/{article_id}/research/start",
|
"/articles/{article_id}/research/start",
|
||||||
response_model=AgentJobListResponse,
|
response_model=ResearchStartResponse,
|
||||||
status_code=status.HTTP_202_ACCEPTED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
)
|
)
|
||||||
def post_start_research(
|
def post_start_research(
|
||||||
article_id: UUID,
|
article_id: UUID,
|
||||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
repository: BackendRepository = Depends(get_repository),
|
repository: BackendRepository = Depends(get_repository),
|
||||||
) -> AgentJobListResponse:
|
) -> ResearchStartResponse:
|
||||||
try:
|
try:
|
||||||
return start_research(repository, article_id=article_id)
|
return start_research_run(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
object_storage=open_object_storage_client(),
|
||||||
|
)
|
||||||
except LookupError as error:
|
except LookupError as error:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
except PermissionError as error:
|
except PermissionError as error:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/articles/{article_id}/research",
|
||||||
|
response_model=ResearchListResponse,
|
||||||
|
)
|
||||||
|
def get_research(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ResearchListResponse:
|
||||||
|
try:
|
||||||
|
return list_research_runs(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|||||||
@@ -112,8 +112,8 @@ class PlanGenerationReviewPublicApiTest(unittest.TestCase):
|
|||||||
f"/api/articles/{article_id}/research/start",
|
f"/api/articles/{article_id}/research/start",
|
||||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
)
|
)
|
||||||
self.assertEqual(202, allowed_research_response.status_code, allowed_research_response.text)
|
self.assertEqual(201, allowed_research_response.status_code, allowed_research_response.text)
|
||||||
self.assertEqual("RESEARCH", allowed_research_response.json()["jobs"][0]["job_type"])
|
self.assertEqual("RESEARCH", allowed_research_response.json()["job"]["job_type"])
|
||||||
|
|
||||||
detail_response = self.client.get(
|
detail_response = self.client.get(
|
||||||
f"/api/articles/{article_id}",
|
f"/api/articles/{article_id}",
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||||
|
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||||
|
from src.presentation.dependencies import get_repository # noqa: E402
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchManifestPublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.storage_root = Path(self.tmp_dir.name) / "object-storage"
|
||||||
|
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(self.storage_root)
|
||||||
|
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'research.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_approved_plan_research_creates_manifest_with_uploaded_artifacts(self) -> None:
|
||||||
|
article_id = self._create_article_with_approved_plan()
|
||||||
|
|
||||||
|
start_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/research/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, start_response.status_code, start_response.text)
|
||||||
|
body = start_response.json()
|
||||||
|
self.assertEqual("RESEARCH_RUNNING", body["article"]["status"])
|
||||||
|
self.assertEqual("RESEARCH", body["job"]["job_type"])
|
||||||
|
|
||||||
|
manifest = body["manifest"]
|
||||||
|
self.assertTrue(manifest["s3_prefix"])
|
||||||
|
self.assertEqual(article_id, manifest["article_id"])
|
||||||
|
self.assertEqual(body["job"]["id"], manifest["agent_job_id"])
|
||||||
|
self.assertGreaterEqual(len(manifest["artifacts"]), 2)
|
||||||
|
|
||||||
|
object_keys = [artifact["object_key"] for artifact in manifest["artifacts"]]
|
||||||
|
content_hashes = [artifact["content_hash"] for artifact in manifest["artifacts"]]
|
||||||
|
artifact_types = [artifact["artifact_type"] for artifact in manifest["artifacts"]]
|
||||||
|
source_urls = [artifact["source_url"] for artifact in manifest["artifacts"]]
|
||||||
|
|
||||||
|
self.assertEqual(len(object_keys), len(set(object_keys)))
|
||||||
|
self.assertTrue(all(key.startswith(manifest["s3_prefix"]) for key in object_keys))
|
||||||
|
self.assertTrue(all(content_hashes))
|
||||||
|
self.assertIn("source_section_snapshot", artifact_types)
|
||||||
|
self.assertTrue(all(url.startswith("https://") for url in source_urls))
|
||||||
|
|
||||||
|
for artifact in manifest["artifacts"]:
|
||||||
|
metadata = artifact["metadata"]
|
||||||
|
self.assertTrue(metadata["source_title"])
|
||||||
|
self.assertTrue(metadata["domain"])
|
||||||
|
self.assertTrue(metadata["summary"])
|
||||||
|
self.assertTrue(metadata["retrieved_at"])
|
||||||
|
self.assertTrue(metadata["artifact_link"])
|
||||||
|
self.assertIn("search_path", metadata)
|
||||||
|
self.assertIn("selection_criteria", metadata)
|
||||||
|
self.assertIn("head_metadata", metadata)
|
||||||
|
self.assertIn("server_ip_address", metadata)
|
||||||
|
self.assertIn("whois_owner", metadata)
|
||||||
|
self.assertTrue((self.storage_root / artifact["object_key"]).is_file())
|
||||||
|
|
||||||
|
list_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/research",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, list_response.status_code, list_response.text)
|
||||||
|
self.assertEqual([manifest["id"]], [item["id"] for item in list_response.json()["manifests"]])
|
||||||
|
|
||||||
|
rerun_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/research/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, rerun_response.status_code, rerun_response.text)
|
||||||
|
self.assertNotEqual(manifest["id"], rerun_response.json()["manifest"]["id"])
|
||||||
|
self.assertNotEqual(
|
||||||
|
manifest["s3_prefix"],
|
||||||
|
rerun_response.json()["manifest"]["s3_prefix"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_article_with_approved_plan(self) -> 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": "Research auditable AI content operations.",
|
||||||
|
"working_title": "Auditable AI Content Operations",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "auditable AI content",
|
||||||
|
},
|
||||||
|
).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"]:
|
||||||
|
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, response.status_code, response.text)
|
||||||
|
self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
plan = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plan/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()["plan"]
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"test:roles": "node tests/role-navigation.test.mjs",
|
"test:roles": "node tests/role-navigation.test.mjs",
|
||||||
"test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs && node tests/agent_jobs.model.test.mjs && node tests/boundary_questions.model.test.mjs && node tests/plan_review.model.test.mjs",
|
"test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs && node tests/agent_jobs.model.test.mjs && node tests/boundary_questions.model.test.mjs && node tests/plan_review.model.test.mjs && node tests/research_manifest.model.test.mjs",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import ArticleResearchPage from "@/pages/article-research";
|
||||||
|
|
||||||
|
type DynamicArticleResearchPageProps = {
|
||||||
|
params: {
|
||||||
|
articleId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function DynamicArticleResearchPage({
|
||||||
|
params,
|
||||||
|
}: DynamicArticleResearchPageProps) {
|
||||||
|
const { articleId } = params;
|
||||||
|
return <ArticleResearchPage articleId={articleId} />;
|
||||||
|
}
|
||||||
@@ -47,6 +47,9 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
|
|||||||
<li>
|
<li>
|
||||||
<Link href={`/articles/${summary.articleId}/plans`}>Plan review</Link>
|
<Link href={`/articles/${summary.articleId}/plans`}>Plan review</Link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={`/articles/${summary.articleId}/research`}>Research</Link>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Workflow timeline</h3>
|
<h3>Workflow timeline</h3>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { ResearchArtifactManifestSummary } from "@pipeline/shared";
|
||||||
|
|
||||||
|
export type ResearchSourceRow = {
|
||||||
|
manifestId: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
title: string;
|
||||||
|
domain: string;
|
||||||
|
type: string;
|
||||||
|
summary: string;
|
||||||
|
retrievedAt: string;
|
||||||
|
artifactLink: string;
|
||||||
|
objectKey: string;
|
||||||
|
contentHash: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function stringValue(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildResearchSourceRows(
|
||||||
|
manifests: readonly ResearchArtifactManifestSummary[],
|
||||||
|
): ResearchSourceRow[] {
|
||||||
|
return manifests.flatMap((manifest) =>
|
||||||
|
(manifest.artifacts ?? []).map((artifact) => {
|
||||||
|
const metadata = artifact.metadata ?? {};
|
||||||
|
return {
|
||||||
|
manifestId: manifest.id,
|
||||||
|
sourceUrl: artifact.source_url,
|
||||||
|
title: stringValue(metadata.source_title),
|
||||||
|
domain: stringValue(metadata.domain),
|
||||||
|
type: stringValue(metadata.source_type) || artifact.artifact_type,
|
||||||
|
summary: stringValue(metadata.summary),
|
||||||
|
retrievedAt: stringValue(metadata.retrieved_at) || manifest.created_at,
|
||||||
|
artifactLink: stringValue(metadata.artifact_link) || artifact.object_key,
|
||||||
|
objectKey: artifact.object_key,
|
||||||
|
contentHash: artifact.content_hash,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import type { ResearchArtifactManifestSummary } from "@pipeline/shared";
|
||||||
|
|
||||||
|
import { ApiError, startResearch } from "@/shared/pipeline-api";
|
||||||
|
import { buildResearchSourceRows } from "./model";
|
||||||
|
|
||||||
|
type ResearchManifestPanelProps = {
|
||||||
|
articleId: string;
|
||||||
|
initialManifests: readonly ResearchArtifactManifestSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ResearchManifestPanel({
|
||||||
|
articleId,
|
||||||
|
initialManifests,
|
||||||
|
}: ResearchManifestPanelProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [manifests, setManifests] = useState<ResearchArtifactManifestSummary[]>([
|
||||||
|
...initialManifests,
|
||||||
|
]);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [isBusy, setBusy] = useState(false);
|
||||||
|
const rows = buildResearchSourceRows(manifests);
|
||||||
|
|
||||||
|
async function handleStart() {
|
||||||
|
setBusy(true);
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const response = await startResearch(articleId);
|
||||||
|
setManifests((current) => [...current, response.manifest]);
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof ApiError ? error.message : "Unable to start research");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="sectionHeader">
|
||||||
|
<div>
|
||||||
|
<h2>Research</h2>
|
||||||
|
<p>{manifests.length} runs</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={handleStart} disabled={isBusy}>
|
||||||
|
Start research
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{message ? <p className="formError">Error: {message}</p> : null}
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="emptyState">No research sources yet.</p>
|
||||||
|
) : (
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Summary</th>
|
||||||
|
<th>Retrieved</th>
|
||||||
|
<th>Artifact</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={`${row.manifestId}-${row.objectKey}`}>
|
||||||
|
<td>
|
||||||
|
<div>{row.title}</div>
|
||||||
|
<a href={row.sourceUrl}>{row.domain || row.sourceUrl}</a>
|
||||||
|
<div>{row.type}</div>
|
||||||
|
</td>
|
||||||
|
<td>{row.summary}</td>
|
||||||
|
<td>{new Date(row.retrievedAt).toLocaleString()}</td>
|
||||||
|
<td>
|
||||||
|
<div>{row.artifactLink}</div>
|
||||||
|
<small>{row.contentHash}</small>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { ResearchManifestPanel } from "@/features/research-manifest/ui";
|
||||||
|
import { fetchResearch } from "@/shared/pipeline-api";
|
||||||
|
import { RoleNavigation } from "@/widgets/role-navigation";
|
||||||
|
|
||||||
|
type ArticleResearchPageProps = {
|
||||||
|
articleId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ArticleResearchPage({
|
||||||
|
articleId,
|
||||||
|
}: ArticleResearchPageProps) {
|
||||||
|
const response = await fetchResearch(articleId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<header className="pageHeader">
|
||||||
|
<div>
|
||||||
|
<h1>Research</h1>
|
||||||
|
<p>{response.manifests?.length ?? 0} runs</p>
|
||||||
|
</div>
|
||||||
|
<Link href={`/articles/${articleId}`}>Back to article</Link>
|
||||||
|
</header>
|
||||||
|
<RoleNavigation role="EDITOR" />
|
||||||
|
<section className="panel">
|
||||||
|
<ResearchManifestPanel
|
||||||
|
articleId={articleId}
|
||||||
|
initialManifests={response.manifests ?? []}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import type {
|
|||||||
PlanResponse,
|
PlanResponse,
|
||||||
PlanRevisionRequest,
|
PlanRevisionRequest,
|
||||||
PlanUpdateRequest,
|
PlanUpdateRequest,
|
||||||
|
ResearchListResponse,
|
||||||
|
ResearchStartResponse,
|
||||||
ReviewActionResponse,
|
ReviewActionResponse,
|
||||||
ScriptConfigVersionCreateRequest,
|
ScriptConfigVersionCreateRequest,
|
||||||
ScriptConfigVersionListResponse,
|
ScriptConfigVersionListResponse,
|
||||||
@@ -373,3 +375,14 @@ export function requestArticlePlanRevision(
|
|||||||
request,
|
request,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function startResearch(articleId: string): Promise<ResearchStartResponse> {
|
||||||
|
return apiPost<unknown, ResearchStartResponse>(
|
||||||
|
`/api/articles/${articleId}/research/start`,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchResearch(articleId: string): Promise<ResearchListResponse> {
|
||||||
|
return apiGet<ResearchListResponse>(`/api/articles/${articleId}/research`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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/research-manifest/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 { buildResearchSourceRows } = moduleExports;
|
||||||
|
|
||||||
|
const rows = buildResearchSourceRows([
|
||||||
|
{
|
||||||
|
id: "11111111-1111-1111-1111-111111111111",
|
||||||
|
article_id: "22222222-2222-2222-2222-222222222222",
|
||||||
|
agent_job_id: "33333333-3333-3333-3333-333333333333",
|
||||||
|
s3_prefix: "research/article/run",
|
||||||
|
created_at: "2026-05-21T00:00:00Z",
|
||||||
|
artifacts: [
|
||||||
|
{
|
||||||
|
artifact_type: "source_section_snapshot",
|
||||||
|
source_url: "https://example.com/source",
|
||||||
|
object_key: "research/article/run/source-1/section.json",
|
||||||
|
content_hash: "abc123",
|
||||||
|
metadata: {
|
||||||
|
source_title: "Source title",
|
||||||
|
domain: "example.com",
|
||||||
|
source_type: "primary_reference",
|
||||||
|
summary: "Useful source summary",
|
||||||
|
retrieved_at: "2026-05-21T00:00:01Z",
|
||||||
|
artifact_link: "research/article/run/source-1/section.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(rows.length, 1);
|
||||||
|
assert.equal(rows[0].sourceUrl, "https://example.com/source");
|
||||||
|
assert.equal(rows[0].title, "Source title");
|
||||||
|
assert.equal(rows[0].domain, "example.com");
|
||||||
|
assert.equal(rows[0].type, "primary_reference");
|
||||||
|
assert.equal(rows[0].summary, "Useful source summary");
|
||||||
|
assert.equal(rows[0].artifactLink, "research/article/run/source-1/section.json");
|
||||||
@@ -1993,6 +1993,48 @@
|
|||||||
"title": "ResearchArtifactSummary",
|
"title": "ResearchArtifactSummary",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"ResearchListResponse": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"evidence": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/EvidenceSummary"
|
||||||
|
},
|
||||||
|
"title": "Evidence",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"manifests": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ResearchArtifactManifestSummary"
|
||||||
|
},
|
||||||
|
"title": "Manifests",
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "ResearchListResponse",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"ResearchStartResponse": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"article": {
|
||||||
|
"$ref": "#/components/schemas/ArticleSummary"
|
||||||
|
},
|
||||||
|
"job": {
|
||||||
|
"$ref": "#/components/schemas/AgentJobSummary"
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"$ref": "#/components/schemas/ResearchArtifactManifestSummary"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"article",
|
||||||
|
"job",
|
||||||
|
"manifest"
|
||||||
|
],
|
||||||
|
"title": "ResearchStartResponse",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ReviewActionResponse": {
|
"ReviewActionResponse": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -3951,6 +3993,65 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/articles/{article_id}/research": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "get_research_api_articles__article_id__research_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/ResearchListResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Validation Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Get Research",
|
||||||
|
"tags": [
|
||||||
|
"plans"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/articles/{article_id}/research/start": {
|
"/api/articles/{article_id}/research/start": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "post_start_research_api_articles__article_id__research_start_post",
|
"operationId": "post_start_research_api_articles__article_id__research_start_post",
|
||||||
@@ -3983,11 +4084,11 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"202": {
|
"201": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/AgentJobListResponse"
|
"$ref": "#/components/schemas/ResearchStartResponse"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -316,6 +316,17 @@ export type ResearchArtifactSummary = {
|
|||||||
source_url: string;
|
source_url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ResearchListResponse = {
|
||||||
|
evidence?: EvidenceSummary[];
|
||||||
|
manifests?: ResearchArtifactManifestSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResearchStartResponse = {
|
||||||
|
article: ArticleSummary;
|
||||||
|
job: AgentJobSummary;
|
||||||
|
manifest: ResearchArtifactManifestSummary;
|
||||||
|
};
|
||||||
|
|
||||||
export type ReviewActionResponse = {
|
export type ReviewActionResponse = {
|
||||||
review: ReviewSummary;
|
review: ReviewSummary;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,14 +35,14 @@ Development description: Implement explicit auditable web research with source r
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] TDD pre-requirement: before implementation, write one failing behavior test that starts research from an approved plan and verifies a manifest with object keys is produced; proceed one research behavior at a time and record evidence in `Result`.
|
- [x] TDD pre-requirement: before implementation, write one failing behavior test that starts research from an approved plan and verifies a manifest with object keys is produced; proceed one research behavior at a time and record evidence in `Result`.
|
||||||
- [ ] Research cannot start before plan approval.
|
- [x] Research cannot start before plan approval.
|
||||||
- [ ] Research run creates an agent/job record and article status `RESEARCH_RUNNING`.
|
- [x] Research run creates an agent/job record and article status `RESEARCH_RUNNING`.
|
||||||
- [ ] Source-section artifacts are uploaded to object storage.
|
- [x] Source-section artifacts are uploaded to object storage.
|
||||||
- [ ] Manifest records include object keys, hashes, URLs, artifact types, and metadata references.
|
- [x] Manifest records include object keys, hashes, URLs, artifact types, and metadata references.
|
||||||
- [ ] Re-running research creates a new manifest instead of overwriting old artifacts.
|
- [x] Re-running research creates a new manifest instead of overwriting old artifacts.
|
||||||
- [ ] UI can show source URL, title, domain, type, summary, retrieval timestamp, and artifact link.
|
- [x] UI can show source URL, title, domain, type, summary, retrieval timestamp, and artifact link.
|
||||||
- [ ] Demo stack can run with deterministic fake research results.
|
- [x] Demo stack can run with deterministic fake research results.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
@@ -53,9 +53,9 @@ Development description: Implement explicit auditable web research with source r
|
|||||||
|
|
||||||
## Result
|
## Result
|
||||||
|
|
||||||
- Status: Pending execution.
|
- Status: Accepted.
|
||||||
- TDD plan: To be filled during execution.
|
- TDD plan: Added `apps/backend/tests/integration/test_research_manifest_public_api.py` before implementation. The test builds an approved plan, starts research, verifies a manifest with object keys/hashes/source URLs/artifact types/metadata, checks uploaded local object files, lists research runs, and verifies rerun creates a new manifest prefix.
|
||||||
- Red evidence: To be filled during execution.
|
- Red evidence: Initial run failed with `201 != 202`; `POST /api/articles/{article_id}/research/start` only returned a queued `RESEARCH` job and no manifest/artifacts.
|
||||||
- Green evidence: To be filled during execution.
|
- Green evidence: Implemented deterministic fake research fetch, local/S3 object storage adapter, research manifest repository, `ResearchStartResponse`, `ResearchListResponse`, `GET /research`, manifest-linked detail output, and FSD frontend research page at `/articles/[articleId]/research`.
|
||||||
- Refactor notes: To be filled during execution.
|
- Refactor notes: Moved research-start behavior out of the Task 009 placeholder and made it produce an auditable manifest immediately for deterministic demo/tests. `OBJECT_STORAGE_LOCAL_ROOT` enables isolated integration tests; Docker Compose uses the existing S3-compatible MinIO env.
|
||||||
- Verification output: To be filled during execution.
|
- Verification output: `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_research_manifest_public_api.py apps/backend/tests/integration/test_plan_generation_review_public_api.py apps/backend/tests/integration/test_schema_storage_contracts.py apps/backend/tests/contracts/test_generated_contract_artifacts.py` passed. `pnpm --filter @pipeline/frontend test:ui` passed. `pnpm --filter @pipeline/frontend typecheck` passed. `tests/smoke/research-flow.sh` and `tests/smoke/plan-flow.sh` passed with Docker Compose after Docker socket escalation.
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ edited_plan_id="$(printf '%s' "${edited_json}" | json_eval "json.load(sys.stdin)
|
|||||||
request POST "/api/articles/${article_id}/plans/${edited_plan_id}/request-revision" '{"notes":"Smoke revision"}' >/dev/null
|
request POST "/api/articles/${article_id}/plans/${edited_plan_id}/request-revision" '{"notes":"Smoke revision"}' >/dev/null
|
||||||
request POST "/api/articles/${article_id}/plans/${edited_plan_id}/approve" >/dev/null
|
request POST "/api/articles/${article_id}/plans/${edited_plan_id}/approve" >/dev/null
|
||||||
research_json="$(request POST "/api/articles/${article_id}/research/start")"
|
research_json="$(request POST "/api/articles/${article_id}/research/start")"
|
||||||
job_type="$(printf '%s' "${research_json}" | json_eval "json.load(sys.stdin)['jobs'][0]['job_type']")"
|
job_type="$(printf '%s' "${research_json}" | json_eval "json.load(sys.stdin)['job']['job_type']")"
|
||||||
|
|
||||||
if [[ "${job_type}" != "RESEARCH" ]]; then
|
if [[ "${job_type}" != "RESEARCH" ]]; then
|
||||||
echo "Expected RESEARCH job, got ${job_type}" >&2
|
echo "Expected RESEARCH job, got ${job_type}" >&2
|
||||||
|
|||||||
Executable
+112
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
COMPOSE_FILE="${ROOT_DIR}/docker-compose.yml"
|
||||||
|
BACKEND_URL="${BACKEND_URL:-http://localhost:8000}"
|
||||||
|
WAIT_SECONDS="${WAIT_SECONDS:-60}"
|
||||||
|
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||||
|
EDITOR_EMAIL="${EDITOR_EMAIL:-editor@example.com}"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
docker compose -f "${COMPOSE_FILE}" down --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
request() {
|
||||||
|
local method="$1"
|
||||||
|
local path="$2"
|
||||||
|
local body="${3:-}"
|
||||||
|
|
||||||
|
if [[ -n "${body}" ]]; then
|
||||||
|
curl -fsS -X "${method}" -H "Content-Type: application/json" \
|
||||||
|
-H "X-Demo-User-Email: ${EDITOR_EMAIL}" --data "${body}" "${BACKEND_URL}${path}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -fsS -X "${method}" -H "X-Demo-User-Email: ${EDITOR_EMAIL}" "${BACKEND_URL}${path}"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_backend() {
|
||||||
|
local deadline=$((SECONDS + WAIT_SECONDS))
|
||||||
|
while (( SECONDS < deadline )); do
|
||||||
|
if curl -fsS "${BACKEND_URL}/health" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "Timed out waiting for backend at ${BACKEND_URL}" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
json_eval() {
|
||||||
|
local expression="$1"
|
||||||
|
"${PYTHON_BIN}" -c "import json,sys; print(${expression})"
|
||||||
|
}
|
||||||
|
|
||||||
|
cd "${ROOT_DIR}"
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
docker compose -f "${COMPOSE_FILE}" up --build -d
|
||||||
|
wait_backend
|
||||||
|
|
||||||
|
site_id="$(request GET /api/sites | json_eval "json.load(sys.stdin)[0]['site']['id']")"
|
||||||
|
article_body="$(
|
||||||
|
SITE_ID="${site_id}" "${PYTHON_BIN}" - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"target_site_id": os.environ["SITE_ID"],
|
||||||
|
"brief_description": f"Research smoke flow {uuid.uuid4()}",
|
||||||
|
"working_title": "Research Smoke Flow",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "research smoke flow",
|
||||||
|
}))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
article_id="$(request POST /api/articles "${article_body}" | json_eval "json.load(sys.stdin)['article']['id']")"
|
||||||
|
|
||||||
|
questions_json="$(request POST "/api/articles/${article_id}/boundary-questions/generate")"
|
||||||
|
question_patch_commands="$(
|
||||||
|
QUESTIONS_JSON="${questions_json}" "${PYTHON_BIN}" - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
for question in json.loads(os.environ["QUESTIONS_JSON"])["questions"]:
|
||||||
|
if question["is_required"]:
|
||||||
|
print(f"{question['id']}\tAccepted answer for {question['category']}.")
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
while IFS=$'\t' read -r question_id answer; do
|
||||||
|
[[ -z "${question_id}" ]] && continue
|
||||||
|
patch_body="$(ANSWER="${answer}" "${PYTHON_BIN}" - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
print(json.dumps({"answer": os.environ["ANSWER"]}))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
request PATCH "/api/articles/${article_id}/boundary-questions/${question_id}" "${patch_body}" >/dev/null
|
||||||
|
done <<< "${question_patch_commands}"
|
||||||
|
request POST "/api/articles/${article_id}/boundary-questions/submit" >/dev/null
|
||||||
|
|
||||||
|
plan_id="$(request POST "/api/articles/${article_id}/plan/generate" | json_eval "json.load(sys.stdin)['plan']['id']")"
|
||||||
|
request POST "/api/articles/${article_id}/plans/${plan_id}/approve" >/dev/null
|
||||||
|
|
||||||
|
research_json="$(request POST "/api/articles/${article_id}/research/start")"
|
||||||
|
manifest_id="$(printf '%s' "${research_json}" | json_eval "json.load(sys.stdin)['manifest']['id']")"
|
||||||
|
artifact_count="$(printf '%s' "${research_json}" | json_eval "len(json.load(sys.stdin)['manifest']['artifacts'])")"
|
||||||
|
if (( artifact_count < 2 )); then
|
||||||
|
echo "Expected at least 2 research artifacts, got ${artifact_count}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
research_list_json="$(request GET "/api/articles/${article_id}/research")"
|
||||||
|
listed_id="$(printf '%s' "${research_list_json}" | json_eval "json.load(sys.stdin)['manifests'][0]['id']")"
|
||||||
|
if [[ "${listed_id}" != "${manifest_id}" ]]; then
|
||||||
|
echo "Expected manifest ${manifest_id}, got ${listed_id}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "research flow smoke ok"
|
||||||
Reference in New Issue
Block a user