from __future__ import annotations import html import json import shutil from datetime import datetime from pathlib import Path from typing import Any from .config import SiteSettings try: from markdown_it import MarkdownIt except ImportError: # pragma: no cover MarkdownIt = None _MARKDOWN = ( MarkdownIt("commonmark", {"html": False, "linkify": True}).enable(["table", "strikethrough"]) if MarkdownIt else None ) def build_site(archive_dir: Path, site_dir: Path, settings: SiteSettings) -> list[dict[str, Any]]: archive_dir = Path(archive_dir) site_dir = Path(site_dir) manifests = _load_run_manifests(archive_dir) if site_dir.exists(): shutil.rmtree(site_dir) (site_dir / "assets").mkdir(parents=True, exist_ok=True) _write_text(site_dir / "assets" / "style.css", _STYLE_CSS) for manifest in manifests: run_dir = Path(manifest["_run_dir"]) _copy_artifacts(site_dir, run_dir, manifest) _write_text( site_dir / "runs" / manifest["run_id"] / "index.html", _render_run_page(manifest, settings), ) for ticker_summary in manifest.get("tickers", []): _write_text( site_dir / "runs" / manifest["run_id"] / f"{ticker_summary['ticker']}.html", _render_ticker_page(manifest, ticker_summary, settings), ) _write_text(site_dir / "index.html", _render_index_page(manifests, settings)) _write_json( site_dir / "feed.json", { "generated_at": datetime.now().isoformat(), "runs": [ {key: value for key, value in manifest.items() if key != "_run_dir"} for manifest in manifests ], }, ) return manifests def _load_run_manifests(archive_dir: Path) -> list[dict[str, Any]]: manifests: list[dict[str, Any]] = [] runs_root = archive_dir / "runs" if not runs_root.exists(): return manifests for path in runs_root.rglob("run.json"): payload = json.loads(path.read_text(encoding="utf-8")) payload["_run_dir"] = str(path.parent) manifests.append(payload) manifests.sort(key=lambda item: item.get("started_at", ""), reverse=True) return manifests def _copy_artifacts(site_dir: Path, run_dir: Path, manifest: dict[str, Any]) -> None: for ticker_summary in manifest.get("tickers", []): download_dir = site_dir / "downloads" / manifest["run_id"] / ticker_summary["ticker"] download_dir.mkdir(parents=True, exist_ok=True) for relative_path in (ticker_summary.get("artifacts") or {}).values(): if not relative_path: continue source = run_dir / relative_path if source.is_file(): shutil.copy2(source, download_dir / source.name) def _render_index_page(manifests: list[dict[str, Any]], settings: SiteSettings) -> str: latest = manifests[0] if manifests else None latest_html = ( f"""

Latest automated run

{_escape(settings.title)}

{_escape(settings.subtitle)}

{_escape(latest['status'].replace('_', ' '))}

Run ID{_escape(latest['run_id'])}

Started{_escape(latest['started_at'])}

Tickers{latest['summary']['total_tickers']}

Success{latest['summary']['successful_tickers']}

Failed{latest['summary']['failed_tickers']}

Open latest run
""" if latest else f"""

Waiting for first run

{_escape(settings.title)}

{_escape(settings.subtitle)}

no data yet

The scheduled workflow has not produced an archived run yet.

""" ) cards = [] for manifest in manifests[: settings.max_runs_on_homepage]: cards.append( f"""
{_escape(manifest['run_id'])} {_escape(manifest['status'].replace('_', ' '))}

{_escape(manifest['started_at'])}

{manifest['summary']['successful_tickers']} succeeded, {manifest['summary']['failed_tickers']} failed

{_escape(manifest['settings']['provider'])} / {_escape(manifest['settings']['deep_model'])}

""" ) body = latest_html + f"""

Recent runs

{len(manifests)} archived run(s)

{''.join(cards) if cards else '

No archived runs were found.

'}
""" return _page_template(settings.title, body, prefix="") def _render_run_page(manifest: dict[str, Any], settings: SiteSettings) -> str: ticker_cards = [] for ticker_summary in manifest.get("tickers", []): ticker_cards.append( f"""
{_escape(ticker_summary['ticker'])} {_escape(ticker_summary['status'])}

Trade date{_escape(ticker_summary.get('trade_date') or '-')}

Duration{ticker_summary.get('duration_seconds', 0):.1f}s

Decision{_escape(ticker_summary.get('decision') or ticker_summary.get('error') or '-')}

""" ) body = f"""

Run detail

{_escape(manifest['run_id'])}

{_escape(manifest['started_at'])}

{_escape(manifest['status'].replace('_', ' '))}

Provider{_escape(manifest['settings']['provider'])}

Deep model{_escape(manifest['settings']['deep_model'])}

Quick model{_escape(manifest['settings']['quick_model'])}

Language{_escape(manifest['settings']['output_language'])}

Tickers

{manifest['summary']['successful_tickers']} success / {manifest['summary']['failed_tickers']} failed

{''.join(ticker_cards)}
""" return _page_template(f"{manifest['run_id']} | {settings.title}", body, prefix="../../") def _render_ticker_page( manifest: dict[str, Any], ticker_summary: dict[str, Any], settings: SiteSettings, ) -> str: run_dir = Path(manifest["_run_dir"]) report_html = "

No report markdown was generated for this ticker.

" report_relative = (ticker_summary.get("artifacts") or {}).get("report_markdown") if report_relative: report_path = run_dir / report_relative if report_path.exists(): report_html = _render_markdown(report_path.read_text(encoding="utf-8")) download_links = [] for relative_path in (ticker_summary.get("artifacts") or {}).values(): if not relative_path: continue artifact_name = Path(relative_path).name download_links.append( f"{_escape(artifact_name)}" ) failure_html = "" if ticker_summary["status"] != "success": failure_html = ( "
" "

Failure

" f"
{_escape(ticker_summary.get('error') or 'Unknown error')}
" "
" ) body = f"""

Ticker report

{_escape(ticker_summary['ticker'])}

{_escape(ticker_summary.get('trade_date') or '-')} / {_escape(ticker_summary['status'])}

{_escape(ticker_summary['status'])}

Decision{_escape(ticker_summary.get('decision') or '-')}

Duration{ticker_summary.get('duration_seconds', 0):.1f}s

LLM calls{ticker_summary.get('metrics', {}).get('llm_calls', 0)}

Tool calls{ticker_summary.get('metrics', {}).get('tool_calls', 0)}

Artifacts

{''.join(download_links) if download_links else "No downloadable artifacts"}
{failure_html}

Rendered report

{report_html}
""" return _page_template( f"{ticker_summary['ticker']} | {settings.title}", body, prefix="../../", ) def _page_template(title: str, body: str, *, prefix: str) -> str: return f""" {_escape(title)}
{body}
""" def _render_markdown(content: str) -> str: if _MARKDOWN is None: return f"
{_escape(content)}
" return _MARKDOWN.render(content) def _write_text(path: Path, content: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") def _write_json(path: Path, payload: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") def _escape(value: object) -> str: return html.escape(str(value)) _STYLE_CSS = """ :root { --bg: #f4efe7; --paper: rgba(255, 255, 255, 0.84); --ink: #132238; --muted: #5d6c7d; --line: rgba(19, 34, 56, 0.12); --accent: #0f7c82; --success: #1f7a4d; --warning: #c46a1c; --danger: #b23b3b; --shadow: 0 18px 45px rgba(17, 34, 51, 0.12); } * { box-sizing: border-box; } body { margin: 0; color: var(--ink); font-family: Aptos, "Segoe UI", "Noto Sans KR", sans-serif; background: radial-gradient(circle at top right, rgba(15, 124, 130, 0.16), transparent 34%), radial-gradient(circle at top left, rgba(196, 106, 28, 0.16), transparent 28%), linear-gradient(180deg, #f8f3eb 0%, #eef4f5 100%); } a { color: inherit; } .shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 24px 0 56px; } .hero { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.9fr); gap: 20px; padding: 28px; border: 1px solid var(--line); border-radius: 28px; background: linear-gradient(135deg, rgba(255,255,255,0.9), rgba(248,251,252,0.9)); box-shadow: var(--shadow); } .hero h1, .section h2 { margin: 0; font-family: Georgia, "Times New Roman", serif; letter-spacing: -0.03em; } .hero h1 { font-size: clamp(2.1rem, 4vw, 3.4rem); line-height: 0.95; } .subtitle, .section-head p, .hero-card p, .run-card p, .ticker-card p, .breadcrumbs, .empty { color: var(--muted); } .eyebrow { margin: 0 0 14px; text-transform: uppercase; letter-spacing: 0.16em; font-size: 0.78rem; color: var(--accent); } .hero-card, .run-card, .ticker-card, .section, .error-block, .prose pre { border: 1px solid var(--line); border-radius: 22px; background: var(--paper); box-shadow: var(--shadow); } .hero-card, .run-card, .ticker-card, .section { padding: 18px 20px; } .hero-card p, .ticker-card p { display: flex; justify-content: space-between; gap: 12px; margin: 10px 0; } .status { display: inline-flex; align-items: center; padding: 8px 12px; border-radius: 999px; font-size: 0.82rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 12px; } .status.success { background: rgba(31, 122, 77, 0.12); color: var(--success); } .status.partial_failure, .status.pending { background: rgba(196, 106, 28, 0.14); color: var(--warning); } .status.failed { background: rgba(178, 59, 59, 0.12); color: var(--danger); } .button, .pill { display: inline-flex; align-items: center; text-decoration: none; border-radius: 999px; padding: 10px 16px; font-weight: 600; border: 1px solid rgba(15, 124, 130, 0.22); background: rgba(15, 124, 130, 0.12); } .section { margin-top: 20px; } .section-head, .run-card-header, .ticker-card-header { display: flex; justify-content: space-between; gap: 16px; align-items: baseline; } .run-grid, .ticker-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; } .breadcrumbs { display: flex; gap: 12px; margin: 0 0 12px; } .breadcrumbs a::after { content: "/"; margin-left: 12px; opacity: 0.4; } .breadcrumbs a:last-child::after { display: none; } .pill-row { display: flex; flex-wrap: wrap; gap: 10px; } .prose { line-height: 1.65; } .prose h1, .prose h2, .prose h3 { font-family: Georgia, "Times New Roman", serif; } .prose pre, .error-block { padding: 16px; overflow: auto; white-space: pre-wrap; font-family: Consolas, "Courier New", monospace; } .prose table { width: 100%; border-collapse: collapse; } .prose th, .prose td { border: 1px solid var(--line); padding: 10px; text-align: left; } @media (max-width: 840px) { .hero { grid-template-columns: 1fr; } .shell { width: min(100% - 20px, 1180px); } } """