feat: implement cost report template generator with Claude session tracking (issue #119)
Some checks failed
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
Some checks failed
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
Comprehensive cost tracking system implementation including: - Cost report generator with multiple formats (summary, detailed, audit) - Full CLI integration with cost management commands - Claude session cost tracking and estimation - Professional markdown reports with frontmatter/contentmatter - Automatic cost note generation for issue implementations - Complete test coverage (33 test cases) - Database integration with finance schema initialization 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
396
markitect/finance/session_tracker.py
Normal file
396
markitect/finance/session_tracker.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
Claude Session Cost Tracker for Issue Implementation.
|
||||
|
||||
This module tracks Claude session costs during issue implementation and generates
|
||||
cost notes documenting the expenses associated with completing specific issues.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
from typing import Dict, Any, Optional, List
|
||||
from pathlib import Path
|
||||
|
||||
from .cost_manager import CostItemManager, CostItem
|
||||
from .report_generator import CostReportGenerator, ReportConfig
|
||||
|
||||
|
||||
class SessionCostTracker:
|
||||
"""Tracks Claude session costs and generates cost notes for issues."""
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
"""
|
||||
Initialize session cost tracker.
|
||||
|
||||
Args:
|
||||
db_path: Path to SQLite database file
|
||||
"""
|
||||
self.db_path = db_path
|
||||
self.cost_manager = CostItemManager(db_path)
|
||||
self.report_generator = CostReportGenerator(db_path)
|
||||
|
||||
def estimate_session_cost(self, input_tokens: int, output_tokens: int,
|
||||
model: str = "claude-sonnet-4") -> Dict[str, Any]:
|
||||
"""
|
||||
Estimate Claude session cost based on token usage.
|
||||
|
||||
Args:
|
||||
input_tokens: Number of input tokens used
|
||||
output_tokens: Number of output tokens generated
|
||||
model: Claude model used (affects pricing)
|
||||
|
||||
Returns:
|
||||
Dictionary with cost breakdown
|
||||
"""
|
||||
# Claude pricing (as of 2025 - these would be updated regularly)
|
||||
pricing = {
|
||||
"claude-sonnet-4": {
|
||||
"input_per_million": 3.00, # $3 per million input tokens
|
||||
"output_per_million": 15.00 # $15 per million output tokens
|
||||
},
|
||||
"claude-sonnet-3.5": {
|
||||
"input_per_million": 3.00,
|
||||
"output_per_million": 15.00
|
||||
},
|
||||
"claude-haiku": {
|
||||
"input_per_million": 0.25,
|
||||
"output_per_million": 1.25
|
||||
}
|
||||
}
|
||||
|
||||
if model not in pricing:
|
||||
model = "claude-sonnet-4" # Default fallback
|
||||
|
||||
rates = pricing[model]
|
||||
|
||||
# Calculate costs in USD
|
||||
input_cost_usd = (input_tokens / 1_000_000) * rates["input_per_million"]
|
||||
output_cost_usd = (output_tokens / 1_000_000) * rates["output_per_million"]
|
||||
total_cost_usd = input_cost_usd + output_cost_usd
|
||||
|
||||
# Convert to EUR (approximate rate - in practice would use real-time rates)
|
||||
usd_to_eur = 0.92 # Approximate conversion rate
|
||||
total_cost_eur = total_cost_usd * usd_to_eur
|
||||
|
||||
return {
|
||||
"model": model,
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"total_tokens": input_tokens + output_tokens,
|
||||
"input_cost_usd": round(input_cost_usd, 4),
|
||||
"output_cost_usd": round(output_cost_usd, 4),
|
||||
"total_cost_usd": round(total_cost_usd, 4),
|
||||
"total_cost_eur": round(total_cost_eur, 4),
|
||||
"pricing_rates": rates,
|
||||
"conversion_rate": usd_to_eur
|
||||
}
|
||||
|
||||
def create_issue_cost_item(self, issue_id: int, issue_title: str,
|
||||
session_cost: Dict[str, Any],
|
||||
session_date: Optional[date] = None) -> Optional[int]:
|
||||
"""
|
||||
Create a cost item for an issue implementation session.
|
||||
|
||||
Args:
|
||||
issue_id: Issue ID number
|
||||
issue_title: Title of the issue
|
||||
session_cost: Cost breakdown from estimate_session_cost
|
||||
session_date: Date of the session (defaults to today)
|
||||
|
||||
Returns:
|
||||
ID of created cost item, or None if creation failed
|
||||
"""
|
||||
if session_date is None:
|
||||
session_date = date.today()
|
||||
|
||||
# Get or create AI & ML Services category
|
||||
ai_category = self.cost_manager.get_category_by_name('AI & ML Services')
|
||||
if not ai_category:
|
||||
# Create the category if it doesn't exist
|
||||
category_id = self.cost_manager.create_category(
|
||||
'AI & ML Services',
|
||||
'Claude sessions and AI-powered development tools'
|
||||
)
|
||||
else:
|
||||
category_id = ai_category['id']
|
||||
|
||||
# Create descriptive name and description
|
||||
name = f"Claude Session - Issue #{issue_id}"
|
||||
description = (
|
||||
f"Claude {session_cost['model']} session for implementing '{issue_title}'. "
|
||||
f"{session_cost['total_tokens']:,} tokens "
|
||||
f"({session_cost['input_tokens']:,} input, {session_cost['output_tokens']:,} output)"
|
||||
)
|
||||
|
||||
# Create cost item
|
||||
cost_item = CostItem(
|
||||
category_id=category_id,
|
||||
name=name,
|
||||
description=description,
|
||||
cost_type='one_time',
|
||||
amount_eur=Decimal(str(session_cost['total_cost_eur'])),
|
||||
currency='EUR',
|
||||
starting_from_date=session_date
|
||||
)
|
||||
|
||||
cost_item_id = self.cost_manager.create_cost_item(cost_item)
|
||||
|
||||
if cost_item_id:
|
||||
print(f"✅ Created cost item for Issue #{issue_id}: €{session_cost['total_cost_eur']:.4f}")
|
||||
|
||||
return cost_item_id
|
||||
|
||||
def generate_issue_cost_note(self, issue_id: int, issue_title: str,
|
||||
session_cost: Dict[str, Any],
|
||||
implementation_summary: Optional[str] = None,
|
||||
session_date: Optional[date] = None) -> str:
|
||||
"""
|
||||
Generate a cost note document for an issue implementation.
|
||||
|
||||
Args:
|
||||
issue_id: Issue ID number
|
||||
issue_title: Title of the issue
|
||||
session_cost: Cost breakdown from estimate_session_cost
|
||||
implementation_summary: Summary of what was implemented
|
||||
session_date: Date of the session
|
||||
|
||||
Returns:
|
||||
Markdown content for the cost note
|
||||
"""
|
||||
if session_date is None:
|
||||
session_date = date.today()
|
||||
|
||||
# Prepare frontmatter
|
||||
frontmatter = {
|
||||
"note_type": "issue_cost_tracking",
|
||||
"issue_id": issue_id,
|
||||
"issue_title": issue_title,
|
||||
"session_date": session_date.isoformat(),
|
||||
"claude_model": session_cost['model'],
|
||||
"total_cost_eur": session_cost['total_cost_eur'],
|
||||
"total_cost_usd": session_cost['total_cost_usd'],
|
||||
"total_tokens": session_cost['total_tokens'],
|
||||
"generated_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# Prepare contentmatter
|
||||
contentmatter = {
|
||||
"cost_tracking": {
|
||||
"issue": {
|
||||
"id": issue_id,
|
||||
"title": issue_title,
|
||||
"implementation_date": session_date.isoformat()
|
||||
},
|
||||
"session": {
|
||||
"model": session_cost['model'],
|
||||
"token_usage": {
|
||||
"input_tokens": session_cost['input_tokens'],
|
||||
"output_tokens": session_cost['output_tokens'],
|
||||
"total_tokens": session_cost['total_tokens']
|
||||
},
|
||||
"costs": {
|
||||
"input_cost_usd": session_cost['input_cost_usd'],
|
||||
"output_cost_usd": session_cost['output_cost_usd'],
|
||||
"total_cost_usd": session_cost['total_cost_usd'],
|
||||
"total_cost_eur": session_cost['total_cost_eur'],
|
||||
"conversion_rate": session_cost['conversion_rate']
|
||||
},
|
||||
"pricing_rates": session_cost['pricing_rates']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Build content
|
||||
content_lines = [
|
||||
f"# Issue #{issue_id} Implementation Cost",
|
||||
f"**Issue**: {issue_title}",
|
||||
f"**Date**: {session_date.strftime('%Y-%m-%d')}",
|
||||
f"**Claude Model**: {session_cost['model']}",
|
||||
"",
|
||||
"## Cost Summary",
|
||||
f"- **Total Cost**: €{session_cost['total_cost_eur']:.4f} (${session_cost['total_cost_usd']:.4f} USD)",
|
||||
f"- **Token Usage**: {session_cost['total_tokens']:,} tokens",
|
||||
f"- **Input Tokens**: {session_cost['input_tokens']:,} tokens @ ${session_cost['pricing_rates']['input_per_million']:.2f}/M",
|
||||
f"- **Output Tokens**: {session_cost['output_tokens']:,} tokens @ ${session_cost['pricing_rates']['output_per_million']:.2f}/M",
|
||||
"",
|
||||
"## Cost Breakdown",
|
||||
"",
|
||||
"| Component | Tokens | Rate ($/M) | Cost (USD) | Cost (EUR) |",
|
||||
"|-----------|--------|------------|------------|------------|",
|
||||
f"| Input | {session_cost['input_tokens']:,} | ${session_cost['pricing_rates']['input_per_million']:.2f} | ${session_cost['input_cost_usd']:.4f} | €{session_cost['input_cost_usd'] * session_cost['conversion_rate']:.4f} |",
|
||||
f"| Output | {session_cost['output_tokens']:,} | ${session_cost['pricing_rates']['output_per_million']:.2f} | ${session_cost['output_cost_usd']:.4f} | €{session_cost['output_cost_usd'] * session_cost['conversion_rate']:.4f} |",
|
||||
f"| **Total** | {session_cost['total_tokens']:,} | - | ${session_cost['total_cost_usd']:.4f} | €{session_cost['total_cost_eur']:.4f} |",
|
||||
"",
|
||||
]
|
||||
|
||||
if implementation_summary:
|
||||
content_lines.extend([
|
||||
"## Implementation Summary",
|
||||
implementation_summary,
|
||||
""
|
||||
])
|
||||
|
||||
content_lines.extend([
|
||||
"## Cost Allocation",
|
||||
f"This cost has been allocated to the 'AI & ML Services' category as a one-time expense for issue #{issue_id} implementation.",
|
||||
"",
|
||||
"## Notes",
|
||||
f"- Currency conversion rate: 1 USD = {session_cost['conversion_rate']:.3f} EUR",
|
||||
f"- Pricing based on {session_cost['model']} rates as of {session_date}",
|
||||
"- Token counts and costs are estimates based on session usage",
|
||||
])
|
||||
|
||||
content = "\n".join(content_lines)
|
||||
|
||||
return self._assemble_cost_note(frontmatter, content, contentmatter)
|
||||
|
||||
def _assemble_cost_note(self, frontmatter: Dict[str, Any], content: str,
|
||||
contentmatter: Dict[str, Any]) -> str:
|
||||
"""Assemble complete cost note with frontmatter and contentmatter."""
|
||||
# Convert frontmatter to YAML
|
||||
fm_lines = ["---"]
|
||||
for key, value in frontmatter.items():
|
||||
if isinstance(value, str):
|
||||
fm_lines.append(f'{key}: "{value}"')
|
||||
else:
|
||||
fm_lines.append(f'{key}: {value}')
|
||||
fm_lines.append("---")
|
||||
|
||||
# Convert contentmatter to HTML comment
|
||||
cm_json = json.dumps(contentmatter, indent=2)
|
||||
cm_lines = [
|
||||
"<!--",
|
||||
"contentmatter:",
|
||||
cm_json,
|
||||
"-->"
|
||||
]
|
||||
|
||||
# Assemble final document
|
||||
return "\n".join([
|
||||
"\n".join(fm_lines),
|
||||
"",
|
||||
content,
|
||||
"",
|
||||
"\n".join(cm_lines)
|
||||
])
|
||||
|
||||
def save_issue_cost_note(self, issue_id: int, cost_note_content: str,
|
||||
output_dir: str = "cost_notes") -> str:
|
||||
"""
|
||||
Save issue cost note to file.
|
||||
|
||||
Args:
|
||||
issue_id: Issue ID number
|
||||
cost_note_content: Generated cost note content
|
||||
output_dir: Directory to save cost notes
|
||||
|
||||
Returns:
|
||||
Path to saved file
|
||||
"""
|
||||
# Create output directory
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate filename
|
||||
today = date.today().strftime('%Y-%m-%d')
|
||||
filename = f"issue_{issue_id:03d}_cost_{today}.md"
|
||||
file_path = output_path / filename
|
||||
|
||||
# Save file
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(cost_note_content)
|
||||
|
||||
return str(file_path)
|
||||
|
||||
def track_issue_completion(self, issue_id: int, issue_title: str,
|
||||
input_tokens: int, output_tokens: int,
|
||||
model: str = "claude-sonnet-4",
|
||||
implementation_summary: Optional[str] = None,
|
||||
save_note: bool = True,
|
||||
output_dir: str = "cost_notes") -> Dict[str, Any]:
|
||||
"""
|
||||
Complete workflow: estimate cost, create cost item, generate note.
|
||||
|
||||
Args:
|
||||
issue_id: Issue ID number
|
||||
issue_title: Title of the issue
|
||||
input_tokens: Number of input tokens used
|
||||
output_tokens: Number of output tokens generated
|
||||
model: Claude model used
|
||||
implementation_summary: Summary of implementation
|
||||
save_note: Whether to save the cost note to file
|
||||
output_dir: Directory to save cost notes
|
||||
|
||||
Returns:
|
||||
Dictionary with tracking results
|
||||
"""
|
||||
# Estimate session cost
|
||||
session_cost = self.estimate_session_cost(input_tokens, output_tokens, model)
|
||||
|
||||
# Create cost item in database
|
||||
cost_item_id = self.create_issue_cost_item(issue_id, issue_title, session_cost)
|
||||
|
||||
# Generate cost note
|
||||
cost_note = self.generate_issue_cost_note(
|
||||
issue_id, issue_title, session_cost, implementation_summary
|
||||
)
|
||||
|
||||
# Save cost note if requested
|
||||
saved_path = None
|
||||
if save_note:
|
||||
saved_path = self.save_issue_cost_note(issue_id, cost_note, output_dir)
|
||||
|
||||
return {
|
||||
"issue_id": issue_id,
|
||||
"issue_title": issue_title,
|
||||
"session_cost": session_cost,
|
||||
"cost_item_id": cost_item_id,
|
||||
"cost_note": cost_note,
|
||||
"saved_path": saved_path,
|
||||
"tracking_successful": cost_item_id is not None
|
||||
}
|
||||
|
||||
def get_issue_costs_summary(self, issue_ids: Optional[List[int]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get summary of costs for specific issues or all AI service costs.
|
||||
|
||||
Args:
|
||||
issue_ids: Optional list of issue IDs to filter by
|
||||
|
||||
Returns:
|
||||
Summary of issue implementation costs
|
||||
"""
|
||||
# Get AI & ML Services category
|
||||
ai_category = self.cost_manager.get_category_by_name('AI & ML Services')
|
||||
if not ai_category:
|
||||
return {"total_costs": 0.0, "issue_count": 0, "items": []}
|
||||
|
||||
# Get cost items in AI category
|
||||
items = self.cost_manager.list_cost_items(
|
||||
active_only=True,
|
||||
category_id=ai_category['id']
|
||||
)
|
||||
|
||||
# Filter by issue IDs if specified
|
||||
if issue_ids:
|
||||
filtered_items = []
|
||||
for item in items:
|
||||
# Extract issue ID from name (format: "Claude Session - Issue #123")
|
||||
if "Issue #" in item['name']:
|
||||
try:
|
||||
item_issue_id = int(item['name'].split("Issue #")[1].split()[0])
|
||||
if item_issue_id in issue_ids:
|
||||
filtered_items.append(item)
|
||||
except (IndexError, ValueError):
|
||||
continue
|
||||
items = filtered_items
|
||||
|
||||
total_cost = sum(float(item['amount_eur']) for item in items)
|
||||
|
||||
return {
|
||||
"total_costs": total_cost,
|
||||
"issue_count": len(items),
|
||||
"items": items,
|
||||
"currency": "EUR"
|
||||
}
|
||||
Reference in New Issue
Block a user