feat(battlecard): create battle card module scaffolding with FIA data model, mini-chart engine, claim extractor, and deck generator
This commit is contained in:
BIN
output/battlecards/charts/test_comparison_bar.png
Normal file
BIN
output/battlecards/charts/test_comparison_bar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
output/battlecards/charts/test_horizontal_bar.png
Normal file
BIN
output/battlecards/charts/test_horizontal_bar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
BIN
output/battlecards/charts/test_line_trend.png
Normal file
BIN
output/battlecards/charts/test_line_trend.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
output/battlecards/charts/test_utilization_bar.png
Normal file
BIN
output/battlecards/charts/test_utilization_bar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
13
src/battlecards/__init__.py
Normal file
13
src/battlecards/__init__.py
Normal file
@@ -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",
|
||||
]
|
||||
108
src/battlecards/card_templates.py
Normal file
108
src/battlecards/card_templates.py
Normal file
@@ -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"")
|
||||
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)
|
||||
222
src/battlecards/claim_extractor.py
Normal file
222
src/battlecards/claim_extractor.py
Normal file
@@ -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
|
||||
248
src/battlecards/generate_deck.py
Normal file
248
src/battlecards/generate_deck.py
Normal file
@@ -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())
|
||||
287
src/battlecards/mini_charts.py
Normal file
287
src/battlecards/mini_charts.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user