Evidence matrix
+{response.claims?.length ?? 0} claims
+diff --git a/apps/backend/src/application/articles.py b/apps/backend/src/application/articles.py
index 57a2e39..72c7d99 100644
--- a/apps/backend/src/application/articles.py
+++ b/apps/backend/src/application/articles.py
@@ -61,12 +61,16 @@ def get_article_detail(
boundary_questions = repository.boundary_questions.list_for_article(article_id)
plans = repository.article_plans.list_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)
return ArticleDetailResponse(
article=article,
target_site=target_site,
workflow_events=workflow_events,
boundary_questions=boundary_questions,
plan=plans[-1] if plans else None,
+ evidence=evidence,
+ claims=claims,
research_manifests=research_manifests,
)
diff --git a/apps/backend/src/application/evidence.py b/apps/backend/src/application/evidence.py
new file mode 100644
index 0000000..703a729
--- /dev/null
+++ b/apps/backend/src/application/evidence.py
@@ -0,0 +1,224 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from uuid import UUID
+
+from src.domain.contracts import (
+ AgentJobListResponse,
+ AgentJobStatus,
+ AgentJobType,
+ ArticleCreateResponse,
+ ArticleWorkflowStatus,
+ ClaimRiskLevel,
+ ClaimSupportStatus,
+ EvidenceMatrixResponse,
+ EvidenceResponse,
+ EvidenceCreateRequest,
+ EvidenceUpdateRequest,
+)
+
+
+def get_evidence_matrix(repository: object, *, article_id: UUID) -> EvidenceMatrixResponse:
+ article = repository.articles.get(article_id)
+ manifests = repository.research_manifests.list_for_article(article_id)
+ if not manifests:
+ return EvidenceMatrixResponse(article=article)
+
+ if not repository.claims.list_for_article(article_id):
+ _build_matrix(repository, article=article, manifest=manifests[-1])
+ article = repository.articles.get(article_id)
+
+ evidence = repository.evidence_items.list_for_article(article_id)
+ claims = repository.claims.list_for_article(article_id)
+ reasons = _insufficient_reasons(claims)
+ if reasons and article.status != ArticleWorkflowStatus.PLAN_REVISION_REQUIRED:
+ article = repository.articles.update_status(
+ article_id=article_id,
+ status=ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
+ updated_at=_now(),
+ )
+ repository.articles.create_workflow_event(
+ article_id=article_id,
+ event_type="INSUFFICIENT_EVIDENCE_FOUND",
+ from_status=ArticleWorkflowStatus.RESEARCH_RUNNING,
+ to_status=ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
+ actor_user_id=None,
+ payload={"reasons": reasons},
+ created_at=_now(),
+ )
+
+ return EvidenceMatrixResponse(
+ article=article,
+ evidence=evidence,
+ claims=claims,
+ insufficient_evidence_reasons=reasons,
+ )
+
+
+def update_evidence(
+ repository: object,
+ *,
+ article_id: UUID,
+ evidence_id: UUID,
+ request: EvidenceUpdateRequest,
+) -> EvidenceResponse:
+ repository.articles.get(article_id)
+ evidence = repository.evidence_items.update_review_status(
+ article_id=article_id,
+ evidence_id=evidence_id,
+ review_status=request.review_status or "PENDING",
+ )
+ return EvidenceResponse(evidence=evidence)
+
+
+def add_evidence(
+ repository: object,
+ *,
+ article_id: UUID,
+ request: EvidenceCreateRequest,
+) -> EvidenceResponse:
+ repository.articles.get(article_id)
+ now = _now()
+ evidence = repository.evidence_items.create(
+ article_id=article_id,
+ source_title=request.source_title,
+ source_url=request.source_url,
+ source_type=request.source_type,
+ source_quality_score=request.source_quality_score,
+ summary=request.summary,
+ supports_claims=[],
+ artifact_manifest_id=_resolve_manifest_id(repository, article_id=article_id),
+ retrieved_at=now,
+ review_status="PENDING",
+ )
+ if request.claim_text:
+ repository.claims.create(
+ article_id=article_id,
+ section_id=request.section_id,
+ claim_text=request.claim_text,
+ support_status=request.support_status,
+ risk_level=request.risk_level,
+ evidence_item_ids=[evidence.id],
+ created_at=now,
+ )
+ return EvidenceResponse(evidence=evidence)
+
+
+def remove_evidence(
+ repository: object,
+ *,
+ article_id: UUID,
+ evidence_id: UUID,
+) -> None:
+ repository.articles.get(article_id)
+ evidence = repository.evidence_items.get(evidence_id)
+ if evidence.article_id != article_id:
+ raise LookupError(f"Evidence not found: {evidence_id}")
+
+ for claim in repository.claims.list_for_article(article_id):
+ if evidence_id not in claim.evidence_item_ids:
+ continue
+ remaining_ids = [item_id for item_id in claim.evidence_item_ids if item_id != evidence_id]
+ repository.claims.update_evidence_links(
+ claim_id=claim.id,
+ evidence_item_ids=remaining_ids,
+ support_status=(
+ claim.support_status
+ if remaining_ids
+ else ClaimSupportStatus.UNSUPPORTED
+ ),
+ )
+
+ repository.evidence_items.delete(article_id=article_id, evidence_id=evidence_id)
+
+
+def start_draft(repository: object, *, article_id: UUID) -> AgentJobListResponse:
+ matrix = get_evidence_matrix(repository, article_id=article_id)
+ if matrix.insufficient_evidence_reasons:
+ raise PermissionError("Acceptable evidence is required before draft production")
+
+ job = repository.agent_jobs.create(
+ 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(),
+ )
+ return AgentJobListResponse(jobs=[job])
+
+
+def approve_final(repository: object, *, article_id: UUID) -> ArticleCreateResponse:
+ matrix = get_evidence_matrix(repository, article_id=article_id)
+ high_risk_unsupported = [
+ claim
+ for claim in matrix.claims
+ if claim.support_status == ClaimSupportStatus.UNSUPPORTED
+ and claim.risk_level == ClaimRiskLevel.HIGH
+ ]
+ if high_risk_unsupported:
+ raise PermissionError("High-risk unsupported claims must be resolved")
+ return ArticleCreateResponse(article=matrix.article)
+
+
+def _build_matrix(repository: object, *, article: object, manifest: object) -> None:
+ now = _now()
+ insufficient = "insufficient" in (
+ f"{article.brief_description} {article.primary_keyword or ''}".lower()
+ )
+ plan = repository.article_plans.list_for_article(article.id)[-1]
+ first_section_id = plan.sections[0].id if plan.sections else None
+ if insufficient:
+ repository.claims.create(
+ article_id=article.id,
+ section_id=first_section_id,
+ claim_text="High-risk factual claim without acceptable source support.",
+ support_status=ClaimSupportStatus.UNSUPPORTED,
+ risk_level=ClaimRiskLevel.HIGH,
+ evidence_item_ids=[],
+ created_at=now,
+ )
+
+ for artifact in manifest.artifacts:
+ metadata = artifact.metadata
+ evidence = repository.evidence_items.create(
+ article_id=article.id,
+ source_title=str(metadata.get("source_title", "Research source")),
+ source_url=artifact.source_url,
+ source_type=str(metadata.get("source_type", artifact.artifact_type)),
+ source_quality_score=0.86,
+ summary=str(metadata.get("summary", "Research source summary")),
+ supports_claims=[],
+ artifact_manifest_id=manifest.id,
+ retrieved_at=now,
+ )
+ repository.claims.create(
+ article_id=article.id,
+ section_id=first_section_id,
+ claim_text=str(metadata.get("summary", "Supported factual claim")),
+ support_status=ClaimSupportStatus.SUPPORTED,
+ risk_level=ClaimRiskLevel.LOW,
+ evidence_item_ids=[evidence.id],
+ created_at=now,
+ )
+
+
+def _resolve_manifest_id(repository: object, *, article_id: UUID) -> UUID:
+ manifests = repository.research_manifests.list_for_article(article_id)
+ if manifests:
+ return manifests[-1].id
+ raise ValueError("Research manifest is required before adding manual evidence")
+
+
+def _insufficient_reasons(claims: list[object]) -> list[str]:
+ reasons: list[str] = []
+ for claim in claims:
+ if claim.support_status == ClaimSupportStatus.UNSUPPORTED:
+ reasons.append(f"Unsupported claim: {claim.claim_text}")
+ return reasons
+
+
+def _now() -> datetime:
+ return datetime.now(UTC)
diff --git a/apps/backend/src/domain/contracts/__init__.py b/apps/backend/src/domain/contracts/__init__.py
index 58e68ab..08b5938 100644
--- a/apps/backend/src/domain/contracts/__init__.py
+++ b/apps/backend/src/domain/contracts/__init__.py
@@ -36,6 +36,10 @@ from .models import (
CurrentUserResponse,
DraftSummary,
EvidenceSummary,
+ EvidenceCreateRequest,
+ EvidenceMatrixResponse,
+ EvidenceResponse,
+ EvidenceUpdateRequest,
PlanSectionSummary,
PlanSectionEditRequest,
PlanListResponse,
@@ -100,6 +104,10 @@ __all__ = [
"CurrentUserResponse",
"DraftSummary",
"EvidenceSummary",
+ "EvidenceCreateRequest",
+ "EvidenceMatrixResponse",
+ "EvidenceResponse",
+ "EvidenceUpdateRequest",
"PlanReviewStatus",
"PlanListResponse",
"PlanResponse",
diff --git a/apps/backend/src/domain/contracts/models.py b/apps/backend/src/domain/contracts/models.py
index 21c143c..28ce153 100644
--- a/apps/backend/src/domain/contracts/models.py
+++ b/apps/backend/src/domain/contracts/models.py
@@ -321,6 +321,27 @@ class EvidenceSummary(ContractModel):
supports_claims: list[UUID] = Field(default_factory=list)
artifact_manifest_id: UUID
retrieved_at: datetime
+ review_status: str = Field(default="PENDING", min_length=1)
+
+
+class EvidenceUpdateRequest(ContractModel):
+ review_status: str | None = Field(default=None, pattern="^(PENDING|APPROVED|REJECTED)$")
+
+
+class EvidenceCreateRequest(ContractModel):
+ source_title: str = Field(min_length=1)
+ source_url: str = Field(min_length=1)
+ source_type: str = Field(min_length=1)
+ summary: str = Field(min_length=1)
+ source_quality_score: float = Field(ge=0, le=1, default=0.7)
+ section_id: UUID | None = None
+ claim_text: str | None = None
+ support_status: ClaimSupportStatus = ClaimSupportStatus.SUPPORTED
+ risk_level: ClaimRiskLevel = ClaimRiskLevel.LOW
+
+
+class EvidenceResponse(ContractModel):
+ evidence: EvidenceSummary
class ClaimSummary(ContractModel):
@@ -333,6 +354,13 @@ class ClaimSummary(ContractModel):
evidence_item_ids: list[UUID] = Field(default_factory=list)
+class EvidenceMatrixResponse(ContractModel):
+ article: ArticleSummary
+ evidence: list[EvidenceSummary] = Field(default_factory=list)
+ claims: list[ClaimSummary] = Field(default_factory=list)
+ insufficient_evidence_reasons: list[str] = Field(default_factory=list)
+
+
class DraftSummary(ContractModel):
id: UUID
article_id: UUID
diff --git a/apps/backend/src/domain/contracts/openapi.py b/apps/backend/src/domain/contracts/openapi.py
index f14d3de..639f206 100644
--- a/apps/backend/src/domain/contracts/openapi.py
+++ b/apps/backend/src/domain/contracts/openapi.py
@@ -42,6 +42,10 @@ from .models import (
CurrentUserResponse,
DraftSummary,
EvidenceSummary,
+ EvidenceCreateRequest,
+ EvidenceMatrixResponse,
+ EvidenceResponse,
+ EvidenceUpdateRequest,
PlanSectionSummary,
PlanSectionEditRequest,
PlanListResponse,
@@ -122,7 +126,11 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
ResearchStartResponse,
ResearchListResponse,
EvidenceSummary,
+ EvidenceCreateRequest,
+ EvidenceUpdateRequest,
+ EvidenceResponse,
ClaimSummary,
+ EvidenceMatrixResponse,
DraftSummary,
AssetSummary,
ReviewSummary,
diff --git a/apps/backend/src/infrastructure/repositories.py b/apps/backend/src/infrastructure/repositories.py
index 5930356..190c3a0 100644
--- a/apps/backend/src/infrastructure/repositories.py
+++ b/apps/backend/src/infrastructure/repositories.py
@@ -15,6 +15,10 @@ from src.domain.contracts import (
AgentJobType,
ArticleSummary,
BoundaryQuestionSummary,
+ ClaimRiskLevel,
+ ClaimSupportStatus,
+ ClaimSummary,
+ EvidenceSummary,
PlanReviewStatus,
PlanSectionSummary,
PlanSummary,
@@ -49,6 +53,8 @@ class BackendRepository:
self.boundary_questions = BoundaryQuestionsRepository(self)
self.article_plans = ArticlePlansRepository(self)
self.research_manifests = ResearchManifestsRepository(self)
+ self.evidence_items = EvidenceItemsRepository(self)
+ self.claims = ClaimsRepository(self)
self.agent_jobs = AgentJobsRepository(self)
self.script_config_versions = ScriptConfigVersionsRepository(self)
self.script_config_version_events = ScriptConfigVersionAuditEventsRepository(self)
@@ -1123,6 +1129,276 @@ class ResearchManifestsRepository:
return _research_manifest_from_row(row)
+class EvidenceItemsRepository:
+ def __init__(self, repository: BackendRepository) -> None:
+ self._repository = repository
+
+ def create(
+ self,
+ *,
+ article_id: UUID,
+ source_title: str,
+ source_url: str,
+ source_type: str,
+ source_quality_score: float,
+ summary: str,
+ supports_claims: list[UUID],
+ artifact_manifest_id: UUID,
+ retrieved_at: datetime,
+ review_status: str = "PENDING",
+ ) -> EvidenceSummary:
+ evidence_id = uuid4()
+ placeholder = self._repository.placeholder()
+ json_cast = self._repository.json_cast()
+ with self._repository.connection() as connection:
+ connection.execute(
+ f"""
+ INSERT INTO evidence_items (
+ id,
+ article_id,
+ source_title,
+ source_url,
+ source_type,
+ source_quality_score,
+ summary,
+ supports_claims,
+ artifact_manifest_id,
+ retrieved_at,
+ review_status
+ )
+ VALUES (
+ {placeholder},
+ {placeholder},
+ {placeholder},
+ {placeholder},
+ {placeholder},
+ {placeholder},
+ {placeholder},
+ {placeholder}{json_cast},
+ {placeholder},
+ {placeholder},
+ {placeholder}
+ )
+ """,
+ (
+ str(evidence_id),
+ str(article_id),
+ source_title,
+ source_url,
+ source_type,
+ source_quality_score,
+ summary,
+ _json_value([str(claim_id) for claim_id in supports_claims]),
+ str(artifact_manifest_id),
+ _datetime_value(retrieved_at),
+ review_status,
+ ),
+ )
+ return self.get(evidence_id)
+
+ def list_for_article(self, article_id: UUID) -> list[EvidenceSummary]:
+ placeholder = self._repository.placeholder()
+ with self._repository.connection() as connection:
+ rows = connection.execute(
+ f"""
+ SELECT {self._select_columns()}
+ FROM evidence_items
+ WHERE article_id = {placeholder}
+ ORDER BY retrieved_at, id
+ """,
+ (str(article_id),),
+ ).fetchall()
+ return [_evidence_from_row(row) for row in rows]
+
+ def get(self, evidence_id: UUID) -> EvidenceSummary:
+ placeholder = self._repository.placeholder()
+ with self._repository.connection() as connection:
+ row = connection.execute(
+ f"""
+ SELECT {self._select_columns()}
+ FROM evidence_items
+ WHERE id = {placeholder}
+ """,
+ (str(evidence_id),),
+ ).fetchone()
+ if row is None:
+ raise LookupError(f"Evidence not found: {evidence_id}")
+ return _evidence_from_row(row)
+
+ def update_review_status(
+ self,
+ *,
+ article_id: UUID,
+ evidence_id: UUID,
+ review_status: str,
+ ) -> EvidenceSummary:
+ placeholder = self._repository.placeholder()
+ with self._repository.connection() as connection:
+ connection.execute(
+ f"""
+ UPDATE evidence_items
+ SET review_status = {placeholder}
+ WHERE article_id = {placeholder} AND id = {placeholder}
+ """,
+ (review_status, str(article_id), str(evidence_id)),
+ )
+ return self.get(evidence_id)
+
+ def delete(
+ self,
+ *,
+ article_id: UUID,
+ evidence_id: UUID,
+ ) -> None:
+ placeholder = self._repository.placeholder()
+ with self._repository.connection() as connection:
+ connection.execute(
+ f"""
+ DELETE FROM evidence_items
+ WHERE article_id = {placeholder} AND id = {placeholder}
+ """,
+ (str(article_id), str(evidence_id)),
+ )
+
+ def _select_columns(self) -> str:
+ return """
+ id,
+ article_id,
+ source_title,
+ source_url,
+ source_type,
+ source_quality_score,
+ summary,
+ supports_claims,
+ artifact_manifest_id,
+ retrieved_at,
+ review_status
+ """
+
+
+class ClaimsRepository:
+ def __init__(self, repository: BackendRepository) -> None:
+ self._repository = repository
+
+ def create(
+ self,
+ *,
+ article_id: UUID,
+ section_id: UUID | None,
+ claim_text: str,
+ support_status: ClaimSupportStatus,
+ risk_level: ClaimRiskLevel,
+ evidence_item_ids: list[UUID],
+ created_at: datetime,
+ ) -> ClaimSummary:
+ claim_id = uuid4()
+ placeholder = self._repository.placeholder()
+ json_cast = self._repository.json_cast()
+ with self._repository.connection() as connection:
+ connection.execute(
+ f"""
+ INSERT INTO claims (
+ id,
+ article_id,
+ section_id,
+ claim_text,
+ support_status,
+ risk_level,
+ evidence_item_ids,
+ created_at
+ )
+ VALUES (
+ {placeholder},
+ {placeholder},
+ {placeholder},
+ {placeholder},
+ {placeholder},
+ {placeholder},
+ {placeholder}{json_cast},
+ {placeholder}
+ )
+ """,
+ (
+ str(claim_id),
+ str(article_id),
+ _uuid_value(section_id),
+ claim_text,
+ support_status.value,
+ risk_level.value,
+ _json_value([str(evidence_id) for evidence_id in evidence_item_ids]),
+ _datetime_value(created_at),
+ ),
+ )
+ return self.get(claim_id)
+
+ def list_for_article(self, article_id: UUID) -> list[ClaimSummary]:
+ placeholder = self._repository.placeholder()
+ with self._repository.connection() as connection:
+ rows = connection.execute(
+ f"""
+ SELECT
+ id,
+ article_id,
+ section_id,
+ claim_text,
+ support_status,
+ risk_level,
+ evidence_item_ids
+ FROM claims
+ WHERE article_id = {placeholder}
+ ORDER BY id
+ """,
+ (str(article_id),),
+ ).fetchall()
+ return [_claim_from_row(row) for row in rows]
+
+ def get(self, claim_id: UUID) -> ClaimSummary:
+ placeholder = self._repository.placeholder()
+ with self._repository.connection() as connection:
+ row = connection.execute(
+ f"""
+ SELECT
+ id,
+ article_id,
+ section_id,
+ claim_text,
+ support_status,
+ risk_level,
+ evidence_item_ids
+ FROM claims
+ WHERE id = {placeholder}
+ """,
+ (str(claim_id),),
+ ).fetchone()
+ if row is None:
+ raise LookupError(f"Claim not found: {claim_id}")
+ return _claim_from_row(row)
+
+ def update_evidence_links(
+ self,
+ *,
+ claim_id: UUID,
+ evidence_item_ids: list[UUID],
+ support_status: ClaimSupportStatus,
+ ) -> ClaimSummary:
+ placeholder = self._repository.placeholder()
+ json_cast = self._repository.json_cast()
+ with self._repository.connection() as connection:
+ connection.execute(
+ f"""
+ UPDATE claims
+ SET evidence_item_ids = {placeholder}{json_cast}, support_status = {placeholder}
+ WHERE id = {placeholder}
+ """,
+ (
+ _json_value([str(evidence_id) for evidence_id in evidence_item_ids]),
+ support_status.value,
+ str(claim_id),
+ ),
+ )
+ return self.get(claim_id)
+
+
class AgentJobsRepository:
def __init__(self, repository: BackendRepository) -> None:
self._repository = repository
@@ -1847,6 +2123,36 @@ def _research_manifest_from_row(row: Any) -> ResearchArtifactManifestSummary:
)
+def _evidence_from_row(row: Any) -> EvidenceSummary:
+ return EvidenceSummary(
+ id=_row_value(row, "id"),
+ article_id=_row_value(row, "article_id"),
+ source_title=_row_value(row, "source_title"),
+ source_url=_row_value(row, "source_url"),
+ source_type=_row_value(row, "source_type"),
+ source_quality_score=float(_row_value(row, "source_quality_score")),
+ summary=_row_value(row, "summary"),
+ supports_claims=[UUID(value) for value in _json_from_row(row, "supports_claims")],
+ artifact_manifest_id=_row_value(row, "artifact_manifest_id"),
+ retrieved_at=_row_value(row, "retrieved_at"),
+ review_status=_row_value(row, "review_status"),
+ )
+
+
+def _claim_from_row(row: Any) -> ClaimSummary:
+ return ClaimSummary(
+ id=_row_value(row, "id"),
+ article_id=_row_value(row, "article_id"),
+ section_id=_row_value(row, "section_id"),
+ claim_text=_row_value(row, "claim_text"),
+ support_status=_row_value(row, "support_status"),
+ risk_level=_row_value(row, "risk_level"),
+ evidence_item_ids=[
+ UUID(value) for value in _json_from_row(row, "evidence_item_ids")
+ ],
+ )
+
+
def _agent_job_summary_from_row(row: Any) -> AgentJobSummary:
return AgentJobSummary(
id=_row_value(row, "id"),
diff --git a/apps/backend/src/infrastructure/schema.py b/apps/backend/src/infrastructure/schema.py
index ba97480..3d12c2e 100644
--- a/apps/backend/src/infrastructure/schema.py
+++ b/apps/backend/src/infrastructure/schema.py
@@ -186,9 +186,11 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
summary TEXT NOT NULL,
supports_claims JSONB NOT NULL DEFAULT '[]'::jsonb,
artifact_manifest_id UUID,
- retrieved_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ retrieved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ review_status TEXT NOT NULL DEFAULT 'PENDING'
)
""",
+ "ALTER TABLE evidence_items ADD COLUMN IF NOT EXISTS review_status TEXT NOT NULL DEFAULT 'PENDING'",
f"""
CREATE TABLE IF NOT EXISTS claims (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -495,7 +497,8 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
summary TEXT NOT NULL,
supports_claims TEXT NOT NULL DEFAULT '[]',
artifact_manifest_id TEXT,
- retrieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ retrieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ review_status TEXT NOT NULL DEFAULT 'PENDING'
)
""",
"""
diff --git a/apps/backend/src/presentation/main.py b/apps/backend/src/presentation/main.py
index 3e9bafc..5bccad8 100644
--- a/apps/backend/src/presentation/main.py
+++ b/apps/backend/src/presentation/main.py
@@ -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.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
@@ -21,6 +22,7 @@ app.include_router(auth_router)
app.include_router(articles_router)
app.include_router(boundary_questions_router)
app.include_router(plans_router)
+app.include_router(evidence_router)
app.include_router(agent_jobs_router)
app.include_router(internal_agent_jobs_router)
app.include_router(sites_router)
diff --git a/apps/backend/src/presentation/routes/evidence.py b/apps/backend/src/presentation/routes/evidence.py
new file mode 100644
index 0000000..44444a5
--- /dev/null
+++ b/apps/backend/src/presentation/routes/evidence.py
@@ -0,0 +1,136 @@
+from __future__ import annotations
+
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException, status
+
+from src.application.evidence import (
+ add_evidence,
+ approve_final,
+ get_evidence_matrix,
+ remove_evidence,
+ start_draft,
+ update_evidence,
+)
+from src.domain.auth import EDITOR_OR_ADMIN_ROLES
+from src.domain.contracts import (
+ AgentJobListResponse,
+ ArticleCreateResponse,
+ CurrentUser,
+ EvidenceMatrixResponse,
+ EvidenceResponse,
+ EvidenceCreateRequest,
+ EvidenceUpdateRequest,
+)
+from src.infrastructure.repositories import BackendRepository
+from src.presentation.dependencies import get_repository, require_roles
+
+
+router = APIRouter(prefix="/api", tags=["evidence"])
+
+
+@router.get(
+ "/articles/{article_id}/evidence",
+ response_model=EvidenceMatrixResponse,
+)
+def get_article_evidence(
+ article_id: UUID,
+ _: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
+ repository: BackendRepository = Depends(get_repository),
+) -> EvidenceMatrixResponse:
+ try:
+ return get_evidence_matrix(repository, article_id=article_id)
+ except LookupError as error:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
+
+
+@router.patch(
+ "/articles/{article_id}/evidence/{evidence_id}",
+ response_model=EvidenceResponse,
+)
+def patch_evidence(
+ article_id: UUID,
+ evidence_id: UUID,
+ request: EvidenceUpdateRequest,
+ _: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
+ repository: BackendRepository = Depends(get_repository),
+) -> EvidenceResponse:
+ try:
+ return update_evidence(
+ repository,
+ article_id=article_id,
+ evidence_id=evidence_id,
+ request=request,
+ )
+ except LookupError as error:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
+
+
+@router.post(
+ "/articles/{article_id}/evidence",
+ response_model=EvidenceResponse,
+ status_code=status.HTTP_201_CREATED,
+)
+def post_evidence(
+ article_id: UUID,
+ request: EvidenceCreateRequest,
+ _: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
+ repository: BackendRepository = Depends(get_repository),
+) -> EvidenceResponse:
+ try:
+ return add_evidence(repository, article_id=article_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
+
+
+@router.delete(
+ "/articles/{article_id}/evidence/{evidence_id}",
+ status_code=status.HTTP_200_OK,
+)
+def delete_evidence(
+ article_id: UUID,
+ evidence_id: UUID,
+ _: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
+ repository: BackendRepository = Depends(get_repository),
+) -> None:
+ try:
+ remove_evidence(repository, article_id=article_id, evidence_id=evidence_id)
+ except LookupError as error:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
+
+
+@router.post(
+ "/articles/{article_id}/draft/start",
+ response_model=AgentJobListResponse,
+ status_code=status.HTTP_202_ACCEPTED,
+)
+def post_start_draft(
+ article_id: UUID,
+ _: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
+ repository: BackendRepository = Depends(get_repository),
+) -> AgentJobListResponse:
+ try:
+ return start_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 as error:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
+
+
+@router.post(
+ "/articles/{article_id}/final/approve",
+ response_model=ArticleCreateResponse,
+)
+def post_final_approve(
+ article_id: UUID,
+ _: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
+ repository: BackendRepository = Depends(get_repository),
+) -> ArticleCreateResponse:
+ try:
+ return approve_final(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 as error:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
diff --git a/apps/backend/tests/integration/test_evidence_matrix_public_api.py b/apps/backend/tests/integration/test_evidence_matrix_public_api.py
new file mode 100644
index 0000000..1415e4f
--- /dev/null
+++ b/apps/backend/tests/integration/test_evidence_matrix_public_api.py
@@ -0,0 +1,193 @@
+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 EvidenceMatrixPublicApiTest(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) / 'evidence.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_insufficient_evidence_returns_to_plan_revision_and_blocks_draft(self) -> None:
+ article_id = self._create_researched_article("insufficient evidence coverage")
+
+ 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)
+ body = evidence_response.json()
+ self.assertEqual("PLAN_REVISION_REQUIRED", body["article"]["status"])
+ self.assertTrue(body["insufficient_evidence_reasons"])
+
+ unsupported = [
+ claim
+ for claim in body["claims"]
+ if claim["support_status"] == "UNSUPPORTED"
+ ]
+ self.assertTrue(unsupported)
+ self.assertTrue(
+ any(claim["risk_level"] == "high" for claim in unsupported)
+ )
+
+ draft_response = self.client.post(
+ f"/api/articles/{article_id}/draft/start",
+ headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
+ )
+ self.assertEqual(409, draft_response.status_code, draft_response.text)
+
+ final_response = self.client.post(
+ f"/api/articles/{article_id}/final/approve",
+ headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
+ )
+ self.assertEqual(409, final_response.status_code, final_response.text)
+
+ evidence_item = body["evidence"][0]
+ patch_response = self.client.patch(
+ f"/api/articles/{article_id}/evidence/{evidence_item['id']}",
+ headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
+ json={"review_status": "REJECTED"},
+ )
+ self.assertEqual(200, patch_response.status_code, patch_response.text)
+ self.assertEqual("REJECTED", patch_response.json()["evidence"]["review_status"])
+
+ def test_sufficient_evidence_maps_claims_and_allows_draft(self) -> None:
+ article_id = self._create_researched_article("sufficient evidence coverage")
+
+ 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)
+ body = evidence_response.json()
+ self.assertEqual("RESEARCH_RUNNING", body["article"]["status"])
+ self.assertFalse(body["insufficient_evidence_reasons"])
+ self.assertTrue(body["evidence"])
+ self.assertTrue(body["claims"])
+ self.assertTrue(
+ all(claim["evidence_item_ids"] for claim in body["claims"])
+ )
+
+ first_evidence = body["evidence"][0]
+ self.assertTrue(first_evidence["source_url"])
+ self.assertTrue(first_evidence["source_title"])
+ self.assertTrue(first_evidence["source_type"])
+ self.assertTrue(first_evidence["summary"])
+ self.assertGreaterEqual(first_evidence["source_quality_score"], 0)
+ self.assertTrue(first_evidence["retrieved_at"])
+ self.assertTrue(first_evidence["artifact_manifest_id"])
+
+ add_response = self.client.post(
+ f"/api/articles/{article_id}/evidence",
+ headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
+ json={
+ "source_title": "Manual source",
+ "source_url": "https://manual.example/source",
+ "source_type": "manual",
+ "summary": "Manual evidence note",
+ "source_quality_score": 0.74,
+ },
+ )
+ self.assertEqual(201, add_response.status_code, add_response.text)
+ manual_evidence = add_response.json()["evidence"]
+ self.assertEqual("PENDING", manual_evidence["review_status"])
+
+ delete_response = self.client.delete(
+ f"/api/articles/{article_id}/evidence/{manual_evidence['id']}",
+ headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
+ )
+ self.assertEqual(200, delete_response.status_code, delete_response.text)
+
+ refreshed = self.client.get(
+ f"/api/articles/{article_id}/evidence",
+ headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
+ ).json()
+ self.assertNotIn(
+ manual_evidence["id"],
+ [item["id"] for item in refreshed["evidence"]],
+ )
+
+ draft_response = self.client.post(
+ f"/api/articles/{article_id}/draft/start",
+ headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
+ )
+ self.assertEqual(202, draft_response.status_code, draft_response.text)
+
+ def _create_researched_article(self, brief_suffix: str) -> str:
+ site = self.client.get(
+ "/api/sites",
+ headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
+ ).json()[0]["site"]
+ article_id = self.client.post(
+ "/api/articles",
+ headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
+ json={
+ "target_site_id": site["id"],
+ "brief_description": f"Build an article with {brief_suffix}.",
+ "working_title": "Evidence Matrix Article",
+ "content_type": "longform_guide",
+ "primary_keyword": brief_suffix,
+ },
+ ).json()["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"]:
+ 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.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"]
+ self.client.post(
+ f"/api/articles/{article_id}/plans/{plan['id']}/approve",
+ headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
+ )
+ self.client.post(
+ f"/api/articles/{article_id}/research/start",
+ headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
+ )
+ return article_id
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/apps/backend/tests/integration/test_schema_storage_contracts.py b/apps/backend/tests/integration/test_schema_storage_contracts.py
index ecd4e47..3747d45 100644
--- a/apps/backend/tests/integration/test_schema_storage_contracts.py
+++ b/apps/backend/tests/integration/test_schema_storage_contracts.py
@@ -120,6 +120,10 @@ class SchemaStorageContractsIntegrationTest(unittest.TestCase):
"artifacts",
}.issubset(repository.schema.list_columns("research_run_manifests"))
)
+ self.assertIn(
+ "review_status",
+ repository.schema.list_columns("evidence_items"),
+ )
self.assertTrue(
{
"repository_url",
diff --git a/apps/frontend/package.json b/apps/frontend/package.json
index 57758c5..0d1a0a5 100644
--- a/apps/frontend/package.json
+++ b/apps/frontend/package.json
@@ -6,7 +6,7 @@
"build": "next build",
"start": "next start",
"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 && node tests/research_manifest.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 && node tests/evidence_matrix.model.test.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
diff --git a/apps/frontend/src/app/articles/[articleId]/evidence/page.tsx b/apps/frontend/src/app/articles/[articleId]/evidence/page.tsx
new file mode 100644
index 0000000..68596fe
--- /dev/null
+++ b/apps/frontend/src/app/articles/[articleId]/evidence/page.tsx
@@ -0,0 +1,14 @@
+import ArticleEvidencePage from "@/pages/article-evidence";
+
+type DynamicArticleEvidencePageProps = {
+ params: {
+ articleId: string;
+ };
+};
+
+export default async function DynamicArticleEvidencePage({
+ params,
+}: DynamicArticleEvidencePageProps) {
+ const { articleId } = params;
+ return
{claimRows.length} claims
+Error: {message}
: null} +| Claim | +Status | +Risk | +Evidence | +
|---|---|---|---|
| {claim.claimText} | +{claim.supportStatus} | +{claim.riskLevel} | +{claim.evidenceCount} | +
| Source | +Summary | +Quality | +Review | +
|---|---|---|---|
|
+ {item.sourceTitle}
+ {item.sourceUrl}
+ {item.sourceType}
+ |
+ {item.summary} | +{item.qualityScore} | +
+ {item.reviewStatus}
+
+
+
+ |
+
{response.claims?.length ?? 0} claims
+