feat(battlecard): create battle card module scaffolding with FIA data model, mini-chart engine, claim extractor, and deck generator

This commit is contained in:
Orchestrator
2026-06-05 14:16:18 -05:00
parent 5705c71140
commit 2f85d31f5e
9 changed files with 878 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View 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",
]

View 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"![]({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)

View 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

View 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 (20242026)")
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())

View 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()