Files
markitect-main/markitect/finance/session_tracker.py
tegwick dab6b9fdef
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
feat: implement cost report template generator with Claude session tracking (issue #119)
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>
2025-10-04 01:31:36 +02:00

396 lines
15 KiB
Python

"""
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"
}