diff --git a/src/buildcompiler/execution/executor.py b/src/buildcompiler/execution/executor.py index cad9c4f..4a1a064 100644 --- a/src/buildcompiler/execution/executor.py +++ b/src/buildcompiler/execution/executor.py @@ -171,17 +171,42 @@ def execute( if (not unresolved and not any(pending[s] for s in pending)) else (BuildStatus.PARTIAL_SUCCESS if products else BuildStatus.FAILED) ) - return FullBuildResult( + from buildcompiler.reporting import build_graph, build_report, build_summary + + preliminary_result = FullBuildResult( + status=status, + plan=plan, + build_document=self.context.build_document, + stage_results=stage_results, + graph=None, + final_products=products, + missing_inputs=unresolved, + required_approvals=list(approvals.values()), + warnings=warnings, + summary=None, + report=None, + ) + graph = build_graph(preliminary_result) + report = ( + build_report(preliminary_result, graph=graph) + if self.context.options.reporting.include_detailed_report + else None + ) + final_result = FullBuildResult( status=status, plan=plan, build_document=self.context.build_document, stage_results=stage_results, - graph=self.context.graph, + graph=graph, final_products=products, missing_inputs=unresolved, required_approvals=list(approvals.values()), warnings=warnings, + summary=None, + report=report, ) + final_result.summary = build_summary(final_result) + return final_result def _run_stage(self, stage: Any, request: BuildRequest) -> StageResult: source_document = ( diff --git a/src/buildcompiler/reporting/__init__.py b/src/buildcompiler/reporting/__init__.py index 5e19692..f69bcdc 100644 --- a/src/buildcompiler/reporting/__init__.py +++ b/src/buildcompiler/reporting/__init__.py @@ -1 +1,27 @@ -"""Package scaffolding for clean architecture.""" +"""Reporting models and builders.""" + +from .graph import BuildGraph, BuildGraphEdge, BuildGraphNode, build_graph +from .report import ( + BuildReport, + DependencyChainStep, + RecommendedAction, + RouteReport, + StageReportSection, + build_report, +) +from .summary import BuildSummary, build_summary + +__all__ = [ + "BuildGraph", + "BuildGraphEdge", + "BuildGraphNode", + "BuildReport", + "BuildSummary", + "DependencyChainStep", + "RecommendedAction", + "RouteReport", + "StageReportSection", + "build_graph", + "build_report", + "build_summary", +] diff --git a/src/buildcompiler/reporting/graph.py b/src/buildcompiler/reporting/graph.py new file mode 100644 index 0000000..f9eba65 --- /dev/null +++ b/src/buildcompiler/reporting/graph.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from buildcompiler.domain import FullBuildResult + + +@dataclass(frozen=True) +class BuildGraphNode: + id: str + kind: str + label: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class BuildGraphEdge: + source: str + target: str + relationship: str + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class BuildGraph: + nodes: list[BuildGraphNode] = field(default_factory=list) + edges: list[BuildGraphEdge] = field(default_factory=list) + + def add_node(self, node: BuildGraphNode) -> None: + if node.id not in {n.id for n in self.nodes}: + self.nodes.append(node) + + def add_edge(self, edge: BuildGraphEdge) -> None: + key = (edge.source, edge.target, edge.relationship, tuple(sorted(edge.metadata.items()))) + keys = { + (e.source, e.target, e.relationship, tuple(sorted(e.metadata.items()))) + for e in self.edges + } + if key not in keys: + self.edges.append(edge) + + def to_dict(self) -> dict[str, object]: + return { + "nodes": [ + { + "id": n.id, + "kind": n.kind, + "label": n.label, + "metadata": n.metadata, + } + for n in sorted(self.nodes, key=lambda x: x.id) + ], + "edges": [ + { + "source": e.source, + "target": e.target, + "relationship": e.relationship, + "metadata": e.metadata, + } + for e in sorted( + self.edges, + key=lambda x: (x.source, x.target, x.relationship, sorted(x.metadata.items())), + ) + ], + } + + def summary(self) -> dict[str, object]: + relationship_counts: dict[str, int] = {} + for edge in self.edges: + relationship_counts[edge.relationship] = relationship_counts.get(edge.relationship, 0) + 1 + return { + "node_count": len(self.nodes), + "edge_count": len(self.edges), + "relationship_counts": dict(sorted(relationship_counts.items())), + } + + +def _kind_for_identity(identity: str) -> str: + value = identity.lower() + if "moduledefinition" in value or "/module/" in value: + return "abstract_design" + if "engineered" in value or "region" in value: + return "engineered_region" + if "plasmid" in value: + return "plasmid" + if "strain" in value: + return "strain" + if "plate" in value: + return "plate" + return "part" + + +def build_graph(result: FullBuildResult) -> BuildGraph: + graph = BuildGraph() + + for stage_result in result.stage_results: + stage_node_id = f"stage_result:{stage_result.id}" + graph.add_node(BuildGraphNode(id=stage_node_id, kind="stage_result", label=stage_result.stage.value)) + for request_id in sorted(stage_result.request_ids): + graph.add_node(BuildGraphNode(id=f"request:{request_id}", kind="abstract_design", label=request_id)) + graph.add_edge(BuildGraphEdge(source=f"request:{request_id}", target=stage_node_id, relationship="requires")) + for product in stage_result.products: + graph.add_node(BuildGraphNode(id=product.identity, kind=_kind_for_identity(product.identity), label=product.display_id)) + graph.add_edge(BuildGraphEdge(source=stage_node_id, target=product.identity, relationship="produces")) + for missing in stage_result.missing_inputs: + node_id = f"missing:{missing.missing_identity}" + graph.add_node(BuildGraphNode(id=node_id, kind="missing_input", label=missing.missing_display_id, metadata={"kind": missing.missing_kind})) + graph.add_edge(BuildGraphEdge(source=stage_node_id, target=node_id, relationship="blocks", metadata={"required_stage": str(missing.required_stage)})) + for approval in stage_result.required_approvals: + approval_id = f"approval:{approval.process}" + graph.add_node(BuildGraphNode(id=approval_id, kind="approval", label=approval.process, metadata={"status": approval.status.value})) + graph.add_edge(BuildGraphEdge(source=stage_node_id, target=approval_id, relationship="requires")) + + for product in result.final_products: + graph.add_node(BuildGraphNode(id=product.identity, kind=_kind_for_identity(product.identity), label=product.display_id)) + + for missing in result.missing_inputs: + graph.add_node(BuildGraphNode(id=f"missing:{missing.missing_identity}", kind="missing_input", label=missing.missing_display_id, metadata={"kind": missing.missing_kind})) + + for approval in result.required_approvals: + graph.add_node(BuildGraphNode(id=f"approval:{approval.process}", kind="approval", label=approval.process, metadata={"status": approval.status.value})) + + return graph diff --git a/src/buildcompiler/reporting/report.py b/src/buildcompiler/reporting/report.py new file mode 100644 index 0000000..895dda1 --- /dev/null +++ b/src/buildcompiler/reporting/report.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from typing import Any + +from buildcompiler.domain import BuildStatus, FullBuildResult +from buildcompiler.reporting.graph import BuildGraph, build_graph + + +@dataclass +class StageReportSection: + stage: str + status: str + request_ids: list[str] + product_count: int + missing_input_count: int + approval_count: int + warning_count: int + logs: list[str] = field(default_factory=list) + + +@dataclass +class RouteReport: + source_stage_result_id: str + selected: bool + route: dict[str, Any] + + +@dataclass +class RecommendedAction: + code: str + message: str + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class DependencyChainStep: + source: str + relationship: str + target: str + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class BuildReport: + status: BuildStatus + executive_summary: str + stage_sections: list[StageReportSection] + selected_routes: list[RouteReport] + rejected_alternatives: list[RouteReport] + missing_inputs: list[dict[str, Any]] + required_approvals: list[dict[str, Any]] + warnings: list[dict[str, Any]] + next_actions: list[RecommendedAction] + dependency_chain: list[DependencyChainStep] + graph_summary: dict[str, Any] + + def to_dict(self) -> dict[str, Any]: + data = asdict(self) + data["status"] = self.status.value + return data + + def to_json(self) -> str: + return json.dumps(self.to_dict(), sort_keys=True) + + def to_markdown(self) -> str: + return "\n".join([ + "# Build Report", + f"- Status: `{self.status.value}`", + f"- Stage sections: `{len(self.stage_sections)}`", + f"- Selected routes: `{len(self.selected_routes)}`", + f"- Rejected alternatives: `{len(self.rejected_alternatives)}`", + f"- Missing inputs: `{len(self.missing_inputs)}`", + f"- Required approvals: `{len(self.required_approvals)}`", + f"- Warnings: `{len(self.warnings)}`", + "", + "## Executive Summary", + self.executive_summary, + ]) + + +def _recommended_actions(result: FullBuildResult) -> list[RecommendedAction]: + actions: list[RecommendedAction] = [] + for missing in result.missing_inputs: + kind = missing.missing_kind + if kind == "engineered_region": + actions.append(RecommendedAction("build_lvl1_engineered_region", "Build the missing engineered region through assembly level 1.", {"missing_identity": missing.missing_identity})) + elif kind in {"promoter", "rbs", "cds", "terminator"}: + actions.append(RecommendedAction("run_domestication", "Run domestication for missing part inputs.", {"missing_kind": kind, "missing_identity": missing.missing_identity})) + elif kind in {"backbone", "restriction_enzyme", "ligase", "reagent"}: + actions.append(RecommendedAction("provide_inventory_or_purchase", "Add missing inventory material or enable explicit purchase support.", {"missing_kind": kind, "missing_identity": missing.missing_identity})) + for approval in result.required_approvals: + actions.append(RecommendedAction("grant_required_approval", f"Grant required approval for process '{approval.process}'.", {"process": approval.process})) + for warning in result.warnings: + actions.append(RecommendedAction("inspect_warning", f"Inspect warning {warning.code} for details.", {"code": warning.code})) + # deterministic de-dup + unique: dict[tuple[str, str, str], RecommendedAction] = {} + for action in actions: + meta = json.dumps(action.metadata, sort_keys=True) + unique[(action.code, action.message, meta)] = action + return [unique[k] for k in sorted(unique)] + + +def build_report(result: FullBuildResult, graph: BuildGraph | None = None) -> BuildReport: + report_graph = graph or build_graph(result) + stage_sections = [ + StageReportSection( + stage=sr.stage.value, + status=sr.status.value, + request_ids=sorted(sr.request_ids), + product_count=len(sr.products), + missing_input_count=len(sr.missing_inputs), + approval_count=len(sr.required_approvals), + warning_count=len(sr.warnings), + logs=list(sr.logs), + ) + for sr in result.stage_results + ] + selected_routes: list[RouteReport] = [] + rejected: list[RouteReport] = [] + for sr in result.stage_results: + artifacts = sr.protocol_artifacts or {} + sel = artifacts.get("selected_route") + if isinstance(sel, dict): + selected_routes.append(RouteReport(sr.id, True, sel)) + for route in artifacts.get("rejected_routes", []) or []: + if isinstance(route, dict): + rejected.append(RouteReport(sr.id, False, route)) + + blocker_summary = f"{len(result.missing_inputs)} missing inputs and {len(result.required_approvals)} required approvals" + if result.status == BuildStatus.FAILED: + executive_summary = ( + f"Build failed with {blocker_summary}." + if result.missing_inputs or result.required_approvals + else "Build failed. Review stage logs and warnings for the root cause." + ) + elif result.missing_inputs or result.required_approvals: + executive_summary = f"Build is blocked by {blocker_summary}." + else: + executive_summary = "Build completed without unresolved blockers." + dependency_chain = [ + DependencyChainStep(e.source, e.relationship, e.target, dict(e.metadata)) + for e in sorted(report_graph.edges, key=lambda x: (x.source, x.target, x.relationship)) + if e.relationship in {"blocks", "requires", "produces", "satisfies", "transforms", "plates"} + ] + return BuildReport( + status=result.status, + executive_summary=executive_summary, + stage_sections=stage_sections, + selected_routes=selected_routes, + rejected_alternatives=rejected, + missing_inputs=[asdict(x) | {"source_stage": x.source_stage.value, "required_stage": str(x.required_stage)} for x in result.missing_inputs], + required_approvals=[asdict(x) | {"status": x.status.value} for x in result.required_approvals], + warnings=[asdict(x) | {"stage": x.stage.value if x.stage else None} for x in result.warnings], + next_actions=_recommended_actions(result), + dependency_chain=dependency_chain, + graph_summary=report_graph.summary(), + ) diff --git a/src/buildcompiler/reporting/summary.py b/src/buildcompiler/reporting/summary.py new file mode 100644 index 0000000..a6a9e5a --- /dev/null +++ b/src/buildcompiler/reporting/summary.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from typing import Any + +from buildcompiler.domain import BuildStatus, FullBuildResult + + +@dataclass +class BuildSummary: + status: BuildStatus + final_product_count: int + missing_input_count: int + required_approval_count: int + warning_count: int + + def to_dict(self) -> dict[str, Any]: + data = asdict(self) + data["status"] = self.status.value + return data + + def to_json(self) -> str: + return json.dumps(self.to_dict(), sort_keys=True) + + def to_markdown(self) -> str: + return "\n".join( + [ + "# Build Summary", + f"- Status: `{self.status.value}`", + f"- Final products: `{self.final_product_count}`", + f"- Missing inputs: `{self.missing_input_count}`", + f"- Required approvals: `{self.required_approval_count}`", + f"- Warnings: `{self.warning_count}`", + ] + ) + + +def build_summary(result: FullBuildResult) -> BuildSummary: + return BuildSummary( + status=result.status, + final_product_count=len(result.final_products), + missing_input_count=len(result.missing_inputs), + required_approval_count=len(result.required_approvals), + warning_count=len(result.warnings), + ) diff --git a/tests/unit/execution/test_executor_reporting.py b/tests/unit/execution/test_executor_reporting.py new file mode 100644 index 0000000..6bb55fb --- /dev/null +++ b/tests/unit/execution/test_executor_reporting.py @@ -0,0 +1,39 @@ +from buildcompiler.api import BuildOptions +from buildcompiler.domain import BuildStatus +from buildcompiler.execution import BuildContext, FullBuildExecutor +from buildcompiler.inventory import Inventory +from buildcompiler.planning import BuildPlan +from buildcompiler.sbol import SbolResolver + + +class _NoopStage: + def run(self, request, *, source_document, target_document): + raise AssertionError("No stage runs expected") + + +def _executor(include_detailed_report: bool) -> FullBuildExecutor: + options = BuildOptions() + options.reporting.include_detailed_report = include_detailed_report + ctx = BuildContext( + sbol=SbolResolver(__import__("sbol2").Document()), + inventory=Inventory(), + build_document=__import__("sbol2").Document(), + options=options, + ) + return FullBuildExecutor(context=ctx, lvl2_stage=_NoopStage(), lvl1_stage=_NoopStage(), domestication_stage=_NoopStage()) + + +def test_executor_always_returns_summary(): + result = _executor(False).execute(BuildPlan()) + assert result.summary is not None + assert result.summary.status == BuildStatus.SUCCESS + + +def test_executor_report_optional_off(): + result = _executor(False).execute(BuildPlan()) + assert result.report is None + + +def test_executor_report_optional_on(): + result = _executor(True).execute(BuildPlan()) + assert result.report is not None diff --git a/tests/unit/reporting/conftest.py b/tests/unit/reporting/conftest.py new file mode 100644 index 0000000..e962df7 --- /dev/null +++ b/tests/unit/reporting/conftest.py @@ -0,0 +1,63 @@ +import pytest + +from buildcompiler.domain import ( + ApprovalStatus, + BuildStage, + BuildStatus, + BuildWarning, + FullBuildResult, + IndexedPlasmid, + MaterialState, + MissingBuildInput, + RequiredApproval, + StageResult, + StageStatus, +) + + +@pytest.fixture +def fake_full_build_result(): + def _make(status=BuildStatus.PARTIAL_SUCCESS, with_duplicates=False, with_routes=False): + p1 = IndexedPlasmid(identity="https://x/plasmidA", display_id="plasmidA", state=MaterialState.GENERATED) + products = [p1, p1] if with_duplicates else [p1] + missing = MissingBuildInput( + source_stage=BuildStage.ASSEMBLY_LVL2, + source_design_identity="https://x/mod", + missing_identity="https://x/regionA", + missing_display_id="regionA", + missing_kind="engineered_region", + required_stage=BuildStage.ASSEMBLY_LVL1, + reason="missing region", + ) + approval = RequiredApproval(status=ApprovalStatus.REQUIRED, process="biosafety", reason="needed") + warning = BuildWarning(code="w1", message="warn", stage=BuildStage.ASSEMBLY_LVL2) + artifacts = {} + if with_routes: + artifacts = {"selected_route": {"id": "route-1"}, "rejected_routes": [{"id": "route-2"}]} + stage_result = StageResult( + id="stage-1", + stage=BuildStage.ASSEMBLY_LVL2, + status=StageStatus.BLOCKED, + request_ids=["req-1"], + products=products, + missing_inputs=[missing], + required_approvals=[approval], + warnings=[warning], + protocol_artifacts=artifacts, + logs=["log1"], + ) + return FullBuildResult( + status=status, + plan=object(), + build_document=None, + stage_results=[stage_result], + graph=None, + final_products=products, + missing_inputs=[missing, missing] if with_duplicates else [missing], + required_approvals=[approval], + warnings=[warning], + summary=None, + report=None, + ) + + return _make diff --git a/tests/unit/reporting/test_graph.py b/tests/unit/reporting/test_graph.py new file mode 100644 index 0000000..92f4319 --- /dev/null +++ b/tests/unit/reporting/test_graph.py @@ -0,0 +1,25 @@ +from buildcompiler.reporting import BuildGraph, build_graph + + +def test_build_graph_contains_expected_nodes_edges(fake_full_build_result): + result = fake_full_build_result(with_duplicates=True) + graph = build_graph(result) + assert isinstance(graph, BuildGraph) + node_ids = {n.id for n in graph.nodes} + assert any(i.startswith("stage_result:") for i in node_ids) + assert any(i.startswith("missing:") for i in node_ids) + assert any(i.startswith("approval:") for i in node_ids) + rels = {(e.source, e.target, e.relationship) for e in graph.edges} + assert any(r[2] == "produces" for r in rels) + assert any(r[2] == "blocks" for r in rels) + + +def test_graph_dedup_and_json_safe_and_reporting_only(fake_full_build_result): + result = fake_full_build_result(with_duplicates=True) + before_stage_results = list(result.stage_results) + graph = build_graph(result) + assert len(graph.nodes) == len({n.id for n in graph.nodes}) + data = graph.to_dict() + assert isinstance(data["nodes"], list) + assert isinstance(graph.summary()["relationship_counts"], dict) + assert result.stage_results == before_stage_results diff --git a/tests/unit/reporting/test_report.py b/tests/unit/reporting/test_report.py new file mode 100644 index 0000000..3d8d685 --- /dev/null +++ b/tests/unit/reporting/test_report.py @@ -0,0 +1,38 @@ +from buildcompiler.reporting import BuildReport, build_graph, build_report + + +def test_build_report_includes_sections_routes_blockers_and_serialization(fake_full_build_result): + result = fake_full_build_result(with_routes=True) + graph = build_graph(result) + report = build_report(result, graph=graph) + assert isinstance(report, BuildReport) + assert report.stage_sections + assert report.selected_routes + assert report.rejected_alternatives + assert report.missing_inputs + assert report.required_approvals + assert report.warnings + assert report.next_actions + assert report.dependency_chain + assert report.graph_summary["node_count"] >= 1 + assert "blocked" in report.executive_summary.lower() + assert '"status"' in report.to_json() + assert "# Build Report" in report.to_markdown() + + +def test_build_report_deterministic(fake_full_build_result): + result = fake_full_build_result(with_routes=True) + graph = build_graph(result) + assert build_report(result, graph=graph).to_json() == build_report(result, graph=graph).to_json() + + +def test_build_report_failed_status_mentions_failure_without_blockers(fake_full_build_result): + result = fake_full_build_result(with_routes=False) + result.missing_inputs = [] + result.required_approvals = [] + result.status = result.status.FAILED + + report = build_report(result) + + assert "failed" in report.executive_summary.lower() + assert "completed" not in report.executive_summary.lower() diff --git a/tests/unit/reporting/test_summary.py b/tests/unit/reporting/test_summary.py new file mode 100644 index 0000000..c563a09 --- /dev/null +++ b/tests/unit/reporting/test_summary.py @@ -0,0 +1,21 @@ +from buildcompiler.domain import BuildStatus +from buildcompiler.reporting import BuildSummary, build_summary + + +def test_build_summary_counts_and_serializes(fake_full_build_result): + result = fake_full_build_result(status=BuildStatus.PARTIAL_SUCCESS) + summary = build_summary(result) + assert isinstance(summary, BuildSummary) + assert summary.status == BuildStatus.PARTIAL_SUCCESS + assert summary.final_product_count == len(result.final_products) + assert summary.missing_input_count == len(result.missing_inputs) + assert summary.required_approval_count == len(result.required_approvals) + assert summary.warning_count == len(result.warnings) + assert summary.to_dict()["status"] == "partial_success" + assert '"warning_count"' in summary.to_json() + assert "# Build Summary" in summary.to_markdown() + + +def test_build_summary_deterministic(fake_full_build_result): + result = fake_full_build_result() + assert build_summary(result).to_json() == build_summary(result).to_json()