diff --git a/backend/app/api/v1/generation_sessions.py b/backend/app/api/v1/generation_sessions.py
index b1d6867..9b210da 100644
--- a/backend/app/api/v1/generation_sessions.py
+++ b/backend/app/api/v1/generation_sessions.py
@@ -71,8 +71,10 @@
_multi_workspace_result_to_store,
_handle_workflow_exception,
)
+from app.core.artifact_files import MULTI_WORKSPACE_REPORT_HTML_FILE
+from app.core.artifact_subdirs import REPORT_SUBDIR
from app.services.workspace_pool import WorkspacePoolService
-from app.services.artifact_store import ArtifactStore
+from app.services.artifact_store import ARTIFACTS_BASE, ArtifactStore
from app.services.agent_stream_broker import get_agent_stream_broker
from app.services.mcp_prune import resolve_enabled_mcps_and_set_telemetry
from app.schemas.model_token_usage import ModelTokenUsage
@@ -1662,3 +1664,29 @@ async def download_generation_session_outputs(
detail=f"Failed to build outputs tarball: {e}",
)
+
+@router.get("/{generation_id}/report.html")
+@track_event(event_name="download_report_html_triggered")
+async def download_generation_session_report_html(
+ generation_id: str,
+ request: Request,
+ _: None = Depends(require_generation_session_owner),
+):
+ """
+ Serve the P10Y multi-workspace estimation report as raw HTML.
+
+ Lightweight alternative to ``/outputs`` β a few KB rather than a full
+ tarball of workspace code, so it's fast enough to fetch on a single local
+ TUI keypress. Raises 404 when the report hasn't been written yet (P10Y
+ estimation not complete, or this is an older run predating the report).
+ """
+ report_path = (
+ ARTIFACTS_BASE / generation_id / REPORT_SUBDIR / MULTI_WORKSPACE_REPORT_HTML_FILE
+ )
+ if not report_path.is_file():
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"No HTML report found for generation session {generation_id}.",
+ )
+ return Response(content=report_path.read_bytes(), media_type="text/html")
+
diff --git a/backend/app/core/artifact_files.py b/backend/app/core/artifact_files.py
index 0b49919..2b9f957 100644
--- a/backend/app/core/artifact_files.py
+++ b/backend/app/core/artifact_files.py
@@ -11,3 +11,9 @@
SPEC_COMPLETENESS_FILE = "specification_completeness.md"
IMPLEMENTATION_PLAN_FILE = "IMPLEMENTATION_PLAN.md"
E2E_TEST_PLAN_FILE = "e2e-test-plan.md"
+
+# P10Y multi-workspace estimation report β written by the estimation workflow
+# under ARTIFACTS_BASE/{generation_id}/report/, read back by the report-html
+# API endpoint and the local TUI.
+MULTI_WORKSPACE_REPORT_MD_FILE = "multi-workspace-estimation-report.md"
+MULTI_WORKSPACE_REPORT_HTML_FILE = "multi-workspace-estimation-report.html"
diff --git a/backend/app/core/notifications.py b/backend/app/core/notifications.py
index 6b3378b..cd9e4de 100644
--- a/backend/app/core/notifications.py
+++ b/backend/app/core/notifications.py
@@ -11,7 +11,7 @@
from app.core.config import EmailConfig
from app.core.config import settings
-from app.database.interface import IDatabase
+from app.database.interface import IDatabase, ReadOnlyDatabase
from app.schemas.estimate import (
ComparativeAnalysis,
EstimationMetrics,
@@ -1060,412 +1060,438 @@ def _build_generation_session_email(
db: Optional[IDatabase],
notification_kind: GenerationSessionNotificationKind = GenerationSessionNotificationKind.COMPLETE,
) -> Tuple[str, str]:
- """
- Build HTML and plain text email content for generation session completion.
-
+ """Build HTML and plain text email content for generation session completion.
+
Returns:
Tuple of (html_content, plain_content)
"""
- pre_deploy = notification_kind == GenerationSessionNotificationKind.CODING_COMPLETE_PRE_DEPLOY
- variance = _p10y_variance_summary(result, pre_deploy=pre_deploy)
+ return render_generation_session_report_html(
+ generation_id=generation_id,
+ workspace_ids=workspace_ids,
+ result=result,
+ spec_path=spec_path,
+ db=db,
+ notification_kind=notification_kind,
+ )
- try:
- final_estimate = result.summary.risk_assessment.final_estimate if result.summary.risk_assessment else result.summary.average_hours
- except (AttributeError, KeyError):
- final_estimate = 0.0
-
- try:
- average_hours = result.summary.average_hours
- except (AttributeError, KeyError):
- average_hours = 0.0
-
- try:
- min_hours = result.summary.min_hours
- except (AttributeError, KeyError):
- min_hours = 0.0
-
- try:
- max_hours = result.summary.max_hours
- except (AttributeError, KeyError):
- max_hours = 0.0
- final_estimate_display = (
- "Pending (reported after P10Y phase)" if pre_deploy else f"{final_estimate:.1f} hours"
- )
- average_display = (
- "Pending" if pre_deploy else f"{average_hours:.1f} hours"
- )
- range_display = (
- "Pending (P10Y after deploy)" if pre_deploy else f"{min_hours:.1f} - {max_hours:.1f} hours"
- )
- cost_display = _session_llm_cost_display(result)
-
- # Extract timestamp with safe fallback
- try:
- timestamp = result.timestamp if hasattr(result, 'timestamp') else "Unknown"
- except (AttributeError, KeyError):
- timestamp = "Unknown"
-
- # Extract workspace generations
- try:
- workspace_estimations = result.workspace_estimations if hasattr(result, 'workspace_estimations') else None
- except (AttributeError, KeyError):
- workspace_estimations = None
-
- # Build per-workspace model/token lookup from workspace_estimations
- ws_est_by_name = {}
- if workspace_estimations:
- for ws_est in workspace_estimations:
- try:
- ws_name = getattr(ws_est, 'workspace_name', None)
- if ws_name:
- ws_est_by_name[ws_name] = ws_est
- except (AttributeError, TypeError):
- continue
+def render_generation_session_report_html(
+ generation_id: str,
+ workspace_ids: List[str],
+ result: Any,
+ spec_path: str,
+ db: Optional[ReadOnlyDatabase],
+ notification_kind: GenerationSessionNotificationKind = GenerationSessionNotificationKind.COMPLETE,
+) -> Tuple[str, str]:
+ """Build HTML and plain text P10Y report content, independent of any notifier.
+
+ Shared by ``EmailNotifier`` (sent as an email body) and the P10Y workflow
+ (saved to disk next to the markdown reports, regardless of whether email
+ or Slack are configured).
+
+ Returns:
+ Tuple of (html_content, plain_content)
+ """
+ pre_deploy = notification_kind == GenerationSessionNotificationKind.CODING_COMPLETE_PRE_DEPLOY
+ variance = _p10y_variance_summary(result, pre_deploy=pre_deploy)
- # Pre-compute codegen usage lines once per workspace (reused in HTML + plain-text)
- ws_codegen_lines: dict[str, list[str]] = {
- ws_id: _workspace_codegen_usage_lines(ws_est)
- for ws_id, ws_est in ws_est_by_name.items()
- }
+ try:
+ final_estimate = result.summary.risk_assessment.final_estimate if result.summary.risk_assessment else result.summary.average_hours
+ except (AttributeError, KeyError):
+ final_estimate = 0.0
- # Build repository links
- repo_links = []
- if db:
- for workspace_id in workspace_ids:
- try:
- ws_doc = db.get("workspaces", workspace_id)
- if ws_doc and ws_doc.get("repo_url"):
- repo_url = ws_doc["repo_url"]
- # Remove .git suffix if present
- if repo_url.endswith(".git"):
- repo_url = repo_url[:-4]
- branch_name = GitArchiveService.branch_name(generation_id)
- branch_url = f"{repo_url}/tree/{branch_name}"
-
- # Get model/token info from matching WorkspaceEstimation
- ws_est = ws_est_by_name.get(workspace_id)
- mu_html = getattr(ws_est, "model_usage", None) if ws_est else None
- model_name = (mu_html.model_name if mu_html else None) or None
- input_tokens = mu_html.input_tokens if mu_html else None
- output_tokens = mu_html.output_tokens if mu_html else None
- agent_num_turns = mu_html.num_turns if mu_html else None
- cache_write = mu_html.cache_write_tokens if mu_html else None
- cache_read = mu_html.cache_read_tokens if mu_html else None
- llm_cost = getattr(ws_est, "total_usd_cost", None) if ws_est else None
-
- repo_links.append({
- "workspace_id": workspace_id,
- "repo_url": repo_url,
- "branch_url": branch_url,
- "model_name": model_name,
- "input_tokens": input_tokens,
- "output_tokens": output_tokens,
- "agent_num_turns": agent_num_turns,
- "cache_write_tokens": cache_write,
- "cache_read_tokens": cache_read,
- "total_usd_cost": llm_cost,
- })
- except Exception as e:
- self.logger.warning(f"Failed to retrieve workspace {workspace_id}: {e}")
-
- # Extract component breakdown
- component_breakdown = {}
- if workspace_estimations:
- # Collect all unique components across all workspaces
- all_components = set()
+ try:
+ average_hours = result.summary.average_hours
+ except (AttributeError, KeyError):
+ average_hours = 0.0
+
+ try:
+ min_hours = result.summary.min_hours
+ except (AttributeError, KeyError):
+ min_hours = 0.0
+
+ try:
+ max_hours = result.summary.max_hours
+ except (AttributeError, KeyError):
+ max_hours = 0.0
+
+ final_estimate_display = (
+ "Pending (reported after P10Y phase)" if pre_deploy else f"{final_estimate:.1f} hours"
+ )
+ average_display = (
+ "Pending" if pre_deploy else f"{average_hours:.1f} hours"
+ )
+ range_display = (
+ "Pending (P10Y after deploy)" if pre_deploy else f"{min_hours:.1f} - {max_hours:.1f} hours"
+ )
+ cost_display = _session_llm_cost_display(result)
+
+ # Extract timestamp with safe fallback
+ try:
+ timestamp = result.timestamp if hasattr(result, 'timestamp') else "Unknown"
+ except (AttributeError, KeyError):
+ timestamp = "Unknown"
+
+ # Extract workspace generations
+ try:
+ workspace_estimations = result.workspace_estimations if hasattr(result, 'workspace_estimations') else None
+ except (AttributeError, KeyError):
+ workspace_estimations = None
+
+ # Build per-workspace model/token lookup from workspace_estimations
+ ws_est_by_name = {}
+ if workspace_estimations:
+ for ws_est in workspace_estimations:
+ try:
+ ws_name = getattr(ws_est, 'workspace_name', None)
+ if ws_name:
+ ws_est_by_name[ws_name] = ws_est
+ except (AttributeError, TypeError):
+ continue
+
+ # Pre-compute codegen usage lines once per workspace (reused in HTML + plain-text)
+ ws_codegen_lines: dict[str, list[str]] = {
+ ws_id: _workspace_codegen_usage_lines(ws_est)
+ for ws_id, ws_est in ws_est_by_name.items()
+ }
+
+ # Build repository links
+ repo_links = []
+ if db:
+ for workspace_id in workspace_ids:
+ try:
+ ws_doc = db.get("workspaces", workspace_id)
+ if ws_doc and ws_doc.get("repo_url"):
+ repo_url = ws_doc["repo_url"]
+ # Remove .git suffix if present
+ if repo_url.endswith(".git"):
+ repo_url = repo_url[:-4]
+ branch_name = GitArchiveService.branch_name(generation_id)
+ branch_url = f"{repo_url}/tree/{branch_name}"
+
+ # Get model/token info from matching WorkspaceEstimation
+ ws_est = ws_est_by_name.get(workspace_id)
+ mu_html = getattr(ws_est, "model_usage", None) if ws_est else None
+ model_name = (mu_html.model_name if mu_html else None) or None
+ input_tokens = mu_html.input_tokens if mu_html else None
+ output_tokens = mu_html.output_tokens if mu_html else None
+ agent_num_turns = mu_html.num_turns if mu_html else None
+ cache_write = mu_html.cache_write_tokens if mu_html else None
+ cache_read = mu_html.cache_read_tokens if mu_html else None
+ llm_cost = getattr(ws_est, "total_usd_cost", None) if ws_est else None
+
+ repo_links.append({
+ "workspace_id": workspace_id,
+ "repo_url": repo_url,
+ "branch_url": branch_url,
+ "model_name": model_name,
+ "input_tokens": input_tokens,
+ "output_tokens": output_tokens,
+ "agent_num_turns": agent_num_turns,
+ "cache_write_tokens": cache_write,
+ "cache_read_tokens": cache_read,
+ "total_usd_cost": llm_cost,
+ })
+ except Exception as e:
+ logger.warning(f"Failed to retrieve workspace {workspace_id}: {e}")
+
+ # Extract component breakdown
+ component_breakdown = {}
+ if workspace_estimations:
+ # Collect all unique components across all workspaces
+ all_components = set()
+ for ws_est in workspace_estimations:
+ try:
+ if hasattr(ws_est, 'component_breakdown') and ws_est.component_breakdown:
+ all_components.update(ws_est.component_breakdown.keys())
+ except (AttributeError, TypeError):
+ continue # Skip this workspace if component_breakdown is missing or invalid
+
+ # Build component breakdown with hours per workspace
+ for component_name in sorted(all_components):
+ component_data = {
+ "name": component_name,
+ "workspaces": {}
+ }
for ws_est in workspace_estimations:
try:
- if hasattr(ws_est, 'component_breakdown') and ws_est.component_breakdown:
- all_components.update(ws_est.component_breakdown.keys())
- except (AttributeError, TypeError):
- continue # Skip this workspace if component_breakdown is missing or invalid
-
- # Build component breakdown with hours per workspace
- for component_name in sorted(all_components):
- component_data = {
- "name": component_name,
- "workspaces": {}
- }
- for ws_est in workspace_estimations:
- try:
- if (hasattr(ws_est, 'component_breakdown') and
- component_name in ws_est.component_breakdown):
- comp = ws_est.component_breakdown[component_name]
- workspace_name = getattr(ws_est, 'workspace_name', 'unknown')
- component_data["workspaces"][workspace_name] = {
- "hours": getattr(comp, 'hours', 0.0),
- "new_work": getattr(comp, 'new_work', 0.0),
- "refactor": getattr(comp, 'refactor', 0.0),
- "rework": getattr(comp, 'rework', 0.0),
- "quality_score": getattr(comp, 'quality_score', 0.0)
- }
- except (AttributeError, TypeError, KeyError):
- continue # Skip this workspace/component if data is missing
- component_breakdown[component_name] = component_data
-
- # Build HTML content
- html_parts = []
- html_parts.append("""
-
-
-
-
-
-
- """)
-
- html_parts.append('