diff --git a/output/battlecards/charts/test_comparison_bar.png b/output/battlecards/charts/test_comparison_bar.png new file mode 100644 index 0000000..15b4ac5 Binary files /dev/null and b/output/battlecards/charts/test_comparison_bar.png differ diff --git a/output/battlecards/charts/test_horizontal_bar.png b/output/battlecards/charts/test_horizontal_bar.png new file mode 100644 index 0000000..9bcd002 Binary files /dev/null and b/output/battlecards/charts/test_horizontal_bar.png differ diff --git a/output/battlecards/charts/test_line_trend.png b/output/battlecards/charts/test_line_trend.png new file mode 100644 index 0000000..a75b5f2 Binary files /dev/null and b/output/battlecards/charts/test_line_trend.png differ diff --git a/output/battlecards/charts/test_utilization_bar.png b/output/battlecards/charts/test_utilization_bar.png new file mode 100644 index 0000000..9da06d4 Binary files /dev/null and b/output/battlecards/charts/test_utilization_bar.png differ diff --git a/src/battlecards/__init__.py b/src/battlecards/__init__.py new file mode 100644 index 0000000..e5b416c --- /dev/null +++ b/src/battlecards/__init__.py @@ -0,0 +1,13 @@ +"""Battle card generation module for AI bubble research.""" +from src.battlecards.card_templates import BattleCard, FIASection +from src.battlecards.mini_charts import MiniChartEngine +from src.battlecards.claim_extractor import ClaimExtractor +from src.battlecards.generate_deck import DeckGenerator + +__all__ = [ + "BattleCard", + "FIASection", + "MiniChartEngine", + "ClaimExtractor", + "DeckGenerator", +] diff --git a/src/battlecards/card_templates.py b/src/battlecards/card_templates.py new file mode 100644 index 0000000..3c2a3ac --- /dev/null +++ b/src/battlecards/card_templates.py @@ -0,0 +1,108 @@ +"""FIA (Fact-Impact-Act) battle card data model and Markdown assembly.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class FIASection: + """One section of a Fact-Impact-Act battle card. + + Attributes: + name: Section name (e.g. "Fact", "Impact", "Act"). + content: List of bullet-point strings. + chart_reference: Optional path to a mini-chart PNG file. + """ + + name: str + content: list[str] + chart_reference: Optional[str] = None + + +@dataclass +class BattleCard: + """A single FIA battle card for AI bubble research. + + Attributes: + card_number: Integer 1-8 identifying the card. + title: Human-readable title for the card. + cluster: Cluster grouping — "bubble" or "value". + summary: One-line summary rendered as a Markdown blockquote. + fact: The Fact section of the FIA model. + impact: The Impact section of the FIA model. + act: The Act section of the FIA model. + sources: List of source references. + last_updated: Timestamp string for the card. + """ + + card_number: int + title: str + cluster: str + summary: str + fact: FIASection + impact: FIASection + act: FIASection + sources: list[str] + last_updated: str + + +def render_card(card: BattleCard) -> str: + """Assemble a BattleCard into a Markdown string. + + Parameters + ---------- + card : BattleCard + The card instance to render. + + Returns + ------- + str + Complete Markdown string for the card. + """ + lines: list[str] = [] + + # Header + lines.append(f"# Card {card.card_number}: {card.title}") + lines.append("") + + # Summary blockquote + lines.append(f"> {card.summary}") + lines.append("") + + # Fact section + lines.append("## Fact") + lines.append("") + for bullet in card.fact.content: + lines.append(f"- {bullet}") + lines.append("") + + # Chart reference (if any) + if card.fact.chart_reference: + chart_filename = card.fact.chart_reference.split("/")[-1] + lines.append(f"![]({chart_filename})") + lines.append("") + + # Impact section + lines.append("## Impact") + lines.append("") + for bullet in card.impact.content: + lines.append(f"- {bullet}") + lines.append("") + + # Act section + lines.append("## Act") + lines.append("") + for bullet in card.act.content: + lines.append(f"- {bullet}") + lines.append("") + + # Footer + lines.append("---") + lines.append("") + sources_str = ", ".join(card.sources) + lines.append(f"*Last updated: {card.last_updated} | Sources: {sources_str}*") + lines.append("") + + return "\n".join(lines) diff --git a/src/battlecards/claim_extractor.py b/src/battlecards/claim_extractor.py new file mode 100644 index 0000000..3f7a080 --- /dev/null +++ b/src/battlecards/claim_extractor.py @@ -0,0 +1,222 @@ +"""Claim extraction module for battle card generation. + +Parses narrative documents and data modules to extract +claim/evidence/implication triples suitable for FIA card assembly. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Optional + + +class ClaimExtractor: + """Extract quantified claims from narratives and data modules. + + Methods + ------- + extract_from_narrative(narrative_path: str) -> list[dict] + Parse a narrative Markdown file for claim triples. + extract_from_data(data_module_path: str) -> list[dict] + Extract quantified claims from a Python data module. + map_to_cards(claims: list[dict]) -> dict + Map extracted claims to card numbers (1-8). + + Claim dict format + ----------------- + { + "card_number": int, + "section": "fact" | "impact" | "act", + "claim": str, + "evidence": str, + "source": str, + } + """ + + # Card number to topic mapping for heuristic assignment + _CARD_TOPICS = { + 1: ("valuation", "cape", "market cap", "shiller", "p/e"), + 2: ("infrastructure", "data center", "hyperscaler", "capex"), + 3: ("gpu", "utilization", "tensor", "compute"), + 4: ("startup", "funding", "venture", "valuation disconnect"), + 5: ("enterprise", "deployment", "adoption", "production"), + 6: ("developer", "coding", "programming", "ide"), + 7: ("quality", "security", "vulnerability", "bug"), + 8: ("productivity", "long-term", "trajectory", "efficiency"), + } + + def extract_from_narrative( + self, narrative_path: str + ) -> list[dict]: + """Parse a Markdown narrative for claim/evidence/implication triples. + + Parameters + ---------- + narrative_path : str + Path to the Markdown narrative file. + + Returns + ------- + list[dict] + List of extracted claim dicts. + """ + claims: list[dict] = [] + path = Path(narrative_path) + + if not path.exists(): + return claims + + text = path.read_text(encoding="utf-8") + + # Pattern: bullet points that contain quantitative data + # Matches lines starting with "- " that contain numbers + bullet_pattern = re.compile( + r"^[-*]\s+(.+?[\d%]+\S.*?)$", + re.MULTILINE, + ) + + for match in bullet_pattern.finditer(text): + bullet_text = match.group(1).strip() + + # Extract evidence (numeric data points) + numbers = re.findall( + r"\d+(?:,\d{3})*(?:\.\d+)?[%$]?", + bullet_text, + ) + evidence = ", ".join(numbers) if numbers else "qualitative" + + # Determine section by context keywords + section = self._classify_section(bullet_text) + + # Map to card number by topic + card_number = self._match_topic(bullet_text) + + claim = { + "card_number": card_number, + "section": section, + "claim": bullet_text, + "evidence": evidence, + "source": "case_narrative", + } + claims.append(claim) + + return claims + + def extract_from_data( + self, data_module_path: str + ) -> list[dict]: + """Extract quantified claims from a Python data module. + + Reads module-level list[dict] or dict constants and + extracts notable data points as claims. + + Parameters + ---------- + data_module_path : str + Path to the Python data module file. + + Returns + ------- + list[dict] + List of extracted claim dicts. + """ + claims: list[dict] = [] + path = Path(data_module_path) + + if not path.exists(): + return claims + + text = path.read_text(encoding="utf-8") + + # Extract module-level variable names (list[dict] or dict) + var_pattern = re.compile( + r"^(\w+):\s*list\[dict\].*?=(\[.*?\])", + re.MULTILINE | re.DOTALL, + ) + + module_name = path.stem + + for match in var_pattern.finditer(text): + var_name = match.group(1) + data_str = match.group(2) + + # Extract representative values from the data + numbers = re.findall( + r"[\d]+(?:,[\d]{3})*(?:\.[\d]+)?", + data_str, + ) + + if numbers: + # Take first and last significant values + sample = f"Range: {numbers[0]} to {numbers[-1]}" + card_number = self._match_topic(var_name) + + claim = { + "card_number": card_number, + "section": "fact", + "claim": f"{var_name}: {sample}", + "evidence": sample, + "source": module_name, + } + claims.append(claim) + + return claims + + def map_to_cards(self, claims: list[dict]) -> dict: + """Map a list of claims to card numbers (1-8). + + Parameters + ---------- + claims : list[dict] + List of claim dicts to organize. + + Returns + ------- + dict + Mapping of card_number -> list of claims for that card. + """ + card_map: dict[int, list[dict]] = {i: [] for i in range(1, 9)} + + for claim in claims: + card_num = claim.get("card_number", 1) + # Clamp to valid range + card_num = max(1, min(8, card_num)) + card_map[card_num].append(claim) + + return card_map + + # ----------------------------------------------------------------------- + # Private helpers + # ----------------------------------------------------------------------- + + @staticmethod + def _classify_section(text: str) -> str: + """Classify a text snippet into fact, impact, or act section.""" + lower = text.lower() + if any( + kw in lower + for kw in [ + "risk", "impact", "threat", "consequence", "could", + "would", "may lead", "potential", + ] + ): + return "impact" + if any( + kw in lower + for kw in [ + "should", "recommend", "act", "take action", + "consider", "monitor", "hedge", + ] + ): + return "act" + return "fact" + + @staticmethod + def _match_topic(text: str) -> int: + """Match text to the closest card number by topic keywords.""" + lower = text.lower() + for card_num, keywords in ClaimExtractor._CARD_TOPICS.items(): + if any(kw in lower for kw in keywords): + return card_num + return 1 # Default to card 1 diff --git a/src/battlecards/generate_deck.py b/src/battlecards/generate_deck.py new file mode 100644 index 0000000..e1cffd3 --- /dev/null +++ b/src/battlecards/generate_deck.py @@ -0,0 +1,248 @@ +"""Deck assembly module for battle cards. + +Combines individual card Markdown files into a single, +well-structured evidence deck with cover page, TOC, and source appendix. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path + +from src.battlecards.card_templates import BattleCard, FIASection, render_card + + +# --------------------------------------------------------------------------- +# Card metadata for TOC generation +# --------------------------------------------------------------------------- + +_CARD_METADATA = [ + { + "number": 1, + "filename": "card_01_market_valuation.md", + "title": "Market Valuation Extremes", + "cluster": "bubble", + }, + { + "number": 2, + "filename": "card_02_ai_infrastructure.md", + "title": "AI Infrastructure Buildout", + "cluster": "bubble", + }, + { + "number": 3, + "filename": "card_03_gpu_utilization.md", + "title": "GPU Utilization Paradox", + "cluster": "bubble", + }, + { + "number": 4, + "filename": "card_04_startup_valuations.md", + "title": "Startup Valuation Disconnect", + "cluster": "bubble", + }, + { + "number": 5, + "filename": "card_05_enterprise_deployment.md", + "title": "Real-World Enterprise Deployment", + "cluster": "value", + }, + { + "number": 6, + "filename": "card_06_developer_adoption.md", + "title": "Developer Adoption Reality", + "cluster": "value", + }, + { + "number": 7, + "filename": "card_07_code_quality_caveats.md", + "title": "Code Quality and Security Caveats", + "cluster": "value", + }, + { + "number": 8, + "filename": "card_08_long_term_productivity.md", + "title": "Long-Term Productivity Trajectory", + "cluster": "value", + }, +] + + +class DeckGenerator: + """Assemble individual battle cards into a complete evidence deck. + + Methods + ------- + generate_deck(card_directory: str, output_path: str) -> str + Combine all card Markdown files into a single deck with + cover page, table of contents, and source appendix. + """ + + def generate_deck( + self, card_directory: str, output_path: str + ) -> str: + """Combine all card Markdown files into a single evidence deck. + + Parameters + ---------- + card_directory : str + Path to the directory containing card Markdown files. + output_path : str + Destination path for the assembled deck Markdown file. + + Returns + ------- + str + Absolute path to the generated deck file. + """ + card_dir = Path(card_directory) + output_file = Path(output_path) + output_file.parent.mkdir(parents=True, exist_ok=True) + + # Collect all sources + all_sources: set[str] = set() + + lines: list[str] = [] + + # ---- Cover page ---- + lines.append("# AI Bubble Battle Cards — Evidence Deck") + lines.append("") + lines.append("> Argument-ready, evidence-backed one-pagers for AI market analysis.") + now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") + lines.append(f"> Last updated: {now_str}") + lines.append("") + + # ---- Table of Contents ---- + lines.append("## Table of Contents") + lines.append("") + lines.append("### Cluster A: The Bubble Exists") + lines.append("") + for meta in _CARD_METADATA[:4]: + lines.append( + f"- [{meta['title']}](cards/{meta['filename']})" + ) + lines.append("") + lines.append("### Cluster B: LLMs Are Still Valuable") + lines.append("") + for meta in _CARD_METADATA[4:]: + lines.append( + f"- [{meta['title']}](cards/{meta['filename']})" + ) + lines.append("") + lines.append("---") + lines.append("") + + # ---- Full card content ---- + for meta in _CARD_METADATA: + card_file = card_dir / meta["filename"] + if card_file.exists(): + card_content = card_file.read_text(encoding="utf-8") + lines.append(card_content) + lines.append("") + lines.append("---") + lines.append("") + else: + lines.append(f"*Card {meta['number']} ({meta['title']}) not found.*") + lines.append("") + + # ---- Source Appendix ---- + lines.append("## Source Appendix") + lines.append("") + lines.append("*Primary data sources referenced across all battle cards:*") + lines.append("") + + # If we have collected sources, list them; otherwise provide defaults + if all_sources: + for source in sorted(all_sources): + lines.append(f"- {source}") + else: + lines.append("- Yale/Shiller CAPE data (multpl.com)") + lines.append("- FRED economic indicators") + lines.append("- World Bank debt & GDP datasets") + lines.append("- Industry research reports (2024–2026)") + + lines.append("") + + deck_content = "\n".join(lines) + output_file.write_text(deck_content, encoding="utf-8") + + return str(output_file.resolve()) + + def generate_deck_from_cards( + self, cards: list[BattleCard], output_path: str + ) -> str: + """Generate a deck directly from BattleCard instances. + + Parameters + ---------- + cards : list[BattleCard] + List of BattleCard instances to include. + output_path : str + Destination path for the deck file. + + Returns + ------- + str + Absolute path to the generated deck file. + """ + output_file = Path(output_path) + output_file.parent.mkdir(parents=True, exist_ok=True) + + all_sources: set[str] = set() + for card in cards: + all_sources.update(card.sources) + + lines: list[str] = [] + + # Cover page + lines.append("# AI Bubble Battle Cards — Evidence Deck") + lines.append("") + lines.append("> Argument-ready, evidence-backed one-pagers for AI market analysis.") + now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") + lines.append(f"> Last updated: {now_str}") + lines.append("") + + # TOC + lines.append("## Table of Contents") + lines.append("") + + bubble_cards = [c for c in cards if c.cluster == "bubble"] + value_cards = [c for c in cards if c.cluster == "value"] + + if bubble_cards: + lines.append("### Cluster A: The Bubble Exists") + lines.append("") + for card in sorted(bubble_cards, key=lambda c: c.card_number): + safe_title = card.title.lower().replace(" ", "_") + lines.append(f"- [Card {card.card_number}: {card.title}]") + lines.append("") + + if value_cards: + lines.append("### Cluster B: LLMs Are Still Valuable") + lines.append("") + for card in sorted(value_cards, key=lambda c: c.card_number): + lines.append(f"- [Card {card.card_number}: {card.title}]") + lines.append("") + + lines.append("---") + lines.append("") + + # Card content + for card in sorted(cards, key=lambda c: c.card_number): + rendered = render_card(card) + lines.append(rendered) + lines.append("") + lines.append("---") + lines.append("") + + # Source appendix + lines.append("## Source Appendix") + lines.append("") + for source in sorted(all_sources): + lines.append(f"- {source}") + lines.append("") + + deck_content = "\n".join(lines) + output_file.write_text(deck_content, encoding="utf-8") + + return str(output_file.resolve()) diff --git a/src/battlecards/mini_charts.py b/src/battlecards/mini_charts.py new file mode 100644 index 0000000..9d3600c --- /dev/null +++ b/src/battlecards/mini_charts.py @@ -0,0 +1,287 @@ +"""Mini-chart engine for battle card visualizations. + +Generates compact, themed 5x3 inch charts suitable for embedding +in FIA battle cards. Uses the existing project styling utilities. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import matplotlib +matplotlib.use("Agg") +import matplotlib.figure # noqa: E402 +import matplotlib.pyplot as plt # noqa: E402 + +from src.utils.styling import ( # noqa: E402 + AI_SPEND, + BUBBLE_ZONE, + REVENUE, + GRAY_DARK, + WHITE, + EXPORT_DPI, + get_theme, + apply_theme, +) + +# --------------------------------------------------------------------------- +# Sizing constants +# --------------------------------------------------------------------------- + +MINI_FIGURE_SIZE = (5, 3) # width, height in inches +MINI_DPI = 300 + +MIN_LABEL_FONT_SIZE = 9 +MIN_ANNOTATION_FONT_SIZE = 11 +MIN_TITLE_FONT_SIZE = 13 + + +class MiniChartEngine: + """Engine for generating compact, themed mini-charts for battle cards. + + Each method creates a 5x3 inch figure, applies the project theme, + draws the requested chart type, and saves as a PNG at 300 DPI. + """ + + def generate_line_trend( + self, data: list[dict], title: str, save_path: str + ) -> str: + """Generate a line trend mini-chart. + + Parameters + ---------- + data : list[dict] + Each dict should have 'x' and 'y' keys. + title : str + Chart title. + save_path : str + Output file path. + + Returns + ------- + str + Absolute path to the saved PNG file. + """ + fig, ax = plt.subplots(figsize=MINI_FIGURE_SIZE) + apply_theme(fig, ax) + + x_values = [d["x"] for d in data] + y_values = [d["y"] for d in data] + + ax.plot(x_values, y_values, color=AI_SPEND, linewidth=1.5) + ax.set_title(title, fontsize=MIN_TITLE_FONT_SIZE, fontweight="bold") + ax.tick_params(axis="both", labelsize=MIN_LABEL_FONT_SIZE) + ax.set_xlabel("Time", fontsize=MIN_LABEL_FONT_SIZE) + ax.set_ylabel("Value", fontsize=MIN_LABEL_FONT_SIZE) + + self._save(fig, save_path) + return save_path + + def generate_horizontal_bar( + self, data: list[dict], title: str, save_path: str + ) -> str: + """Generate a horizontal bar mini-chart. + + Parameters + ---------- + data : list[dict] + Each dict should have 'label' and 'value' keys. + title : str + Chart title. + save_path : str + Output file path. + + Returns + ------- + str + Absolute path to the saved PNG file. + """ + fig, ax = plt.subplots(figsize=MINI_FIGURE_SIZE) + apply_theme(fig, ax) + + labels = [d["label"] for d in data] + values = [d["value"] for d in data] + colors = [ + AI_SPEND, BUBBLE_ZONE, REVENUE, GRAY_DARK, WHITE + ] * (len(labels) // 5 + 1) + + ax.barh(labels, values, color=colors[:len(labels)], height=0.6) + ax.set_title(title, fontsize=MIN_TITLE_FONT_SIZE, fontweight="bold") + ax.tick_params(axis="both", labelsize=MIN_LABEL_FONT_SIZE) + + self._save(fig, save_path) + return save_path + + def generate_utilization_bar( + self, data: list[dict], title: str, save_path: str + ) -> str: + """Generate a utilization bar chart (e.g. GPU utilization). + + Parameters + ---------- + data : list[dict] + Each dict should have 'label', 'value', and 'max_value' keys. + title : str + Chart title. + save_path : str + Output file path. + + Returns + ------- + str + Absolute path to the saved PNG file. + """ + fig, ax = plt.subplots(figsize=MINI_FIGURE_SIZE) + apply_theme(fig, ax) + + labels = [d["label"] for d in data] + values = [d["value"] for d in data] + max_values = [d.get("max_value", 100) for d in data] + + # Background bars for max + ax.barh(labels, max_values, color="#ecf0f1", height=0.6) + # Foreground bars for actual utilization + colors = [ + BUBBLE_ZONE if v / m < 0.3 else REVENUE + for v, m in zip(values, max_values) + ] + ax.barh(labels, values, color=colors, height=0.6) + + ax.set_title(title, fontsize=MIN_TITLE_FONT_SIZE, fontweight="bold") + ax.tick_params(axis="both", labelsize=MIN_LABEL_FONT_SIZE) + + self._save(fig, save_path) + return save_path + + def generate_comparison_bar( + self, data: list[dict], title: str, save_path: str + ) -> str: + """Generate a grouped comparison bar chart. + + Parameters + ---------- + data : list[dict] + Each dict should have 'label' and multiple value keys + (e.g. 'value_a', 'value_b'). + title : str + Chart title. + save_path : str + Output file path. + + Returns + ------- + str + Absolute path to the saved PNG file. + """ + fig, ax = plt.subplots(figsize=MINI_FIGURE_SIZE) + apply_theme(fig, ax) + + labels = [d["label"] for d in data] + keys = [k for k in data[0].keys() if k != "label"] + + import numpy as np + + x = np.arange(len(labels)) + width = 0.35 + + for i, key in enumerate(keys): + values = [d.get(key, 0) for d in data] + color = AI_SPEND if i == 0 else REVENUE + ax.bar(x + i * width, values, width, label=key, color=color) + + ax.set_title(title, fontsize=MIN_TITLE_FONT_SIZE, fontweight="bold") + ax.set_xticks(x + width / 2) + ax.set_xticklabels(labels, fontsize=MIN_LABEL_FONT_SIZE) + ax.tick_params(axis="y", labelsize=MIN_LABEL_FONT_SIZE) + ax.legend(fontsize=MIN_LABEL_FONT_SIZE) + + self._save(fig, save_path) + return save_path + + @staticmethod + def _save(fig: matplotlib.figure.Figure, save_path: str) -> None: + """Save a figure with tight layout at MINI_DPI.""" + Path(save_path).parent.mkdir(parents=True, exist_ok=True) + fig.savefig(save_path, dpi=MINI_DPI, bbox_inches="tight") + plt.close(fig) + + +# --------------------------------------------------------------------------- +# CLI test entry point +# --------------------------------------------------------------------------- + +def _run_tests() -> None: + """Generate 4 placeholder test charts to output/battlecards/charts/.""" + base_dir = Path("output/battlecards/charts") + engine = MiniChartEngine() + + # Test 1: Line trend + line_data = [ + {"x": f"Q{i}", "y": 10 + i * 3} for i in range(1, 5) + ] + path1 = engine.generate_line_trend( + line_data, + "AI Spending Trend (Test)", + str(base_dir / "test_line_trend.png"), + ) + print(f" Line trend: {path1}") + + # Test 2: Horizontal bar + bar_data = [ + {"label": "Cloud A", "value": 85}, + {"label": "Cloud B", "value": 62}, + {"label": "On-prem", "value": 28}, + ] + path2 = engine.generate_horizontal_bar( + bar_data, + "AI Infrastructure Spending (Test)", + str(base_dir / "test_horizontal_bar.png"), + ) + print(f" Horizontal bar: {path2}") + + # Test 3: Utilization bar + util_data = [ + {"label": "Company A", "value": 18, "max_value": 100}, + {"label": "Company B", "value": 24, "max_value": 100}, + {"label": "Company C", "value": 45, "max_value": 100}, + ] + path3 = engine.generate_utilization_bar( + util_data, + "GPU Utilization (Test)", + str(base_dir / "test_utilization_bar.png"), + ) + print(f" Utilization bar: {path3}") + + # Test 4: Comparison bar + comp_data = [ + {"label": "2023", "value_a": 50, "value_b": 30}, + {"label": "2024", "value_a": 75, "value_b": 45}, + {"label": "2025", "value_a": 90, "value_b": 55}, + ] + path4 = engine.generate_comparison_bar( + comp_data, + "Spend vs Revenue Comparison (Test)", + str(base_dir / "test_comparison_bar.png"), + ) + print(f" Comparison bar: {path4}") + + print("All 4 test charts generated successfully.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Battle card mini-chart engine" + ) + parser.add_argument( + "--test", + action="store_true", + help="Generate 4 placeholder test charts", + ) + args = parser.parse_args() + + if args.test: + _run_tests() + else: + parser.print_help()