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