Metrics
All checks were successful
Build and Deploy MoneyMaker / build-and-deploy (push) Successful in 12m22s
All checks were successful
Build and Deploy MoneyMaker / build-and-deploy (push) Successful in 12m22s
This commit is contained in:
46
analyze.py
46
analyze.py
@@ -29,6 +29,7 @@ import math
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import logging
|
import logging
|
||||||
import warnings
|
import warnings
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Silence transformer noise before importing
|
# Silence transformer noise before importing
|
||||||
@@ -78,6 +79,37 @@ MIN_COVERAGE_SPREAD = 0.0 # disabled: signal_score naturally zeros out single-
|
|||||||
FINBERT_MIN_CONF = 0.70 # drop neutral articles below this FinBERT confidence
|
FINBERT_MIN_CONF = 0.70 # drop neutral articles below this FinBERT confidence
|
||||||
ALERT_THRESHOLD = 0.35 # signal_score > this → alert
|
ALERT_THRESHOLD = 0.35 # signal_score > this → alert
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Claude metrics
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
METRICS_FILE = Path(__file__).parent / "metrics.json"
|
||||||
|
|
||||||
|
# Pricing: Claude Haiku 4.5 — https://www.anthropic.com/pricing
|
||||||
|
_PRICE_INPUT_PER_TOKEN = 0.80 / 1_000_000 # $0.80 per MTok
|
||||||
|
_PRICE_OUTPUT_PER_TOKEN = 4.00 / 1_000_000 # $4.00 per MTok
|
||||||
|
|
||||||
|
|
||||||
|
def calc_cost(input_tokens: int, output_tokens: int) -> float:
|
||||||
|
return round(input_tokens * _PRICE_INPUT_PER_TOKEN + output_tokens * _PRICE_OUTPUT_PER_TOKEN, 6)
|
||||||
|
|
||||||
|
|
||||||
|
def update_metrics(input_tokens: int, output_tokens: int) -> None:
|
||||||
|
"""Accumulate Claude token usage into metrics.json."""
|
||||||
|
cost = calc_cost(input_tokens, output_tokens)
|
||||||
|
data: dict = {}
|
||||||
|
if METRICS_FILE.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(METRICS_FILE.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
data["total_calls"] = data.get("total_calls", 0) + 1
|
||||||
|
data["total_input_tokens"] = data.get("total_input_tokens", 0) + input_tokens
|
||||||
|
data["total_output_tokens"] = data.get("total_output_tokens", 0) + output_tokens
|
||||||
|
data["total_cost_usd"] = round(data.get("total_cost_usd", 0.0) + cost, 6)
|
||||||
|
data["last_updated"] = datetime.now().isoformat(timespec="seconds")
|
||||||
|
METRICS_FILE.write_text(json.dumps(data, indent=2))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Model loading
|
# Model loading
|
||||||
@@ -195,16 +227,16 @@ def coverage_spread_score(row) -> float:
|
|||||||
# Claude structured extraction
|
# Claude structured extraction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def claude_extract(title: str, text: str, tickers: list[str]) -> dict:
|
def claude_extract(title: str, text: str, tickers: list[str]) -> tuple[dict, int, int]:
|
||||||
"""
|
"""
|
||||||
Use Claude Haiku to extract structured financial signal.
|
Use Claude Haiku to extract structured financial signal.
|
||||||
Returns {"confirmed_tickers", "magnitude", "timeframe", "reasoning"}.
|
Returns ({"confirmed_tickers", "magnitude", "timeframe", "reasoning"}, input_tokens, output_tokens).
|
||||||
"""
|
"""
|
||||||
import anthropic
|
import anthropic
|
||||||
|
|
||||||
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return {"confirmed_tickers": tickers, "magnitude": 5, "timeframe": "days", "reasoning": "(no API key)"}
|
return {"confirmed_tickers": tickers, "magnitude": 5, "timeframe": "days", "reasoning": "(no API key)"}, 0, 0
|
||||||
|
|
||||||
client = anthropic.Anthropic(api_key=api_key)
|
client = anthropic.Anthropic(api_key=api_key)
|
||||||
ticker_ctx = "\n".join(
|
ticker_ctx = "\n".join(
|
||||||
@@ -245,10 +277,10 @@ Fields:
|
|||||||
raw = msg.content[0].text.strip()
|
raw = msg.content[0].text.strip()
|
||||||
raw = re.sub(r"^```(?:json)?\n?", "", raw)
|
raw = re.sub(r"^```(?:json)?\n?", "", raw)
|
||||||
raw = re.sub(r"\n?```$", "", raw)
|
raw = re.sub(r"\n?```$", "", raw)
|
||||||
return json.loads(raw)
|
return json.loads(raw), msg.usage.input_tokens, msg.usage.output_tokens
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" [warn] Claude failed: {e}")
|
print(f" [warn] Claude failed: {e}")
|
||||||
return {"confirmed_tickers": tickers, "magnitude": 5, "timeframe": "days", "reasoning": str(e)[:120]}
|
return {"confirmed_tickers": tickers, "magnitude": 5, "timeframe": "days", "reasoning": str(e)[:120]}, 0, 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -476,7 +508,9 @@ def analyze_articles(
|
|||||||
claude_data: dict = {}
|
claude_data: dict = {}
|
||||||
if use_claude and not dry_run and os.environ.get("ANTHROPIC_API_KEY"):
|
if use_claude and not dry_run and os.environ.get("ANTHROPIC_API_KEY"):
|
||||||
print(f" [claude] {slug[:50]}")
|
print(f" [claude] {slug[:50]}")
|
||||||
claude_data = claude_extract(title, full_text, list(matches.keys()))
|
claude_data, _in_tok, _out_tok = claude_extract(title, full_text, list(matches.keys()))
|
||||||
|
if _in_tok:
|
||||||
|
update_metrics(_in_tok, _out_tok)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Phase 6 — yfinance momentum + scoring
|
# Phase 6 — yfinance momentum + scoring
|
||||||
|
|||||||
334
dashboard.py
334
dashboard.py
@@ -28,10 +28,281 @@ from report import _c25_day_return, _unrealised_pnl, _realised_pnl, _total_fees,
|
|||||||
|
|
||||||
CAPITAL = 10_000
|
CAPITAL = 10_000
|
||||||
LOG_DIR = Path(os.getenv("LOG_DIR", str(Path(__file__).parent / "logs")))
|
LOG_DIR = Path(os.getenv("LOG_DIR", str(Path(__file__).parent / "logs")))
|
||||||
|
METRICS_FILE = Path(__file__).parent / "metrics.json"
|
||||||
|
REFRESH = 60 # seconds
|
||||||
|
|
||||||
|
CAPITAL = 10_000
|
||||||
|
LOG_DIR = Path(os.getenv("LOG_DIR", str(Path(__file__).parent / "logs")))
|
||||||
|
METRICS_FILE = Path(__file__).parent / "metrics.json"
|
||||||
REFRESH = 60 # seconds
|
REFRESH = 60 # seconds
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# ── Metrics dashboard template ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
METRICS_TEMPLATE = """<!DOCTYPE html>
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>MoneyMaker · AI Costs</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #eef2f7;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--text: #0f172a;
|
||||||
|
--muted: #64748b;
|
||||||
|
--accent: #2563eb;
|
||||||
|
--green: #16a34a;
|
||||||
|
--red: #dc2626;
|
||||||
|
--yellow: #d97706;
|
||||||
|
--purple: #7c3aed;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0,0,0,.05);
|
||||||
|
--shadow: 0 1px 3px rgba(0,0,0,.07), 0 1px 2px rgba(0,0,0,.04);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 24px 32px 52px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
margin-bottom: 22px; padding-bottom: 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.header h1 { font-size: 1.25rem; font-weight: 800; letter-spacing: -.03em; }
|
||||||
|
.header h1 span { color: var(--accent); }
|
||||||
|
.header-pill {
|
||||||
|
font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em;
|
||||||
|
color: var(--purple); background: rgba(124,58,237,.09);
|
||||||
|
padding: 3px 9px; border-radius: 20px; border: 1px solid rgba(124,58,237,.18);
|
||||||
|
}
|
||||||
|
.header-meta { font-size: 0.7rem; color: var(--muted); text-align: right; line-height: 1.9; }
|
||||||
|
.back-link {
|
||||||
|
font-size: 0.72rem; color: var(--accent); text-decoration: none; font-weight: 600;
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
}
|
||||||
|
.back-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* KPI strip */
|
||||||
|
.kpis { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; margin-bottom: 18px; }
|
||||||
|
@media (max-width: 1000px) { .kpis { grid-template-columns: repeat(3, 1fr); } }
|
||||||
|
@media (max-width: 600px) { .kpis { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
.kpi {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 10px; padding: 15px 16px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border-top: 3px solid transparent;
|
||||||
|
}
|
||||||
|
.kpi.blue { border-top-color: var(--accent); }
|
||||||
|
.kpi.green { border-top-color: var(--green); }
|
||||||
|
.kpi.red { border-top-color: var(--red); }
|
||||||
|
.kpi.yellow { border-top-color: var(--yellow); }
|
||||||
|
.kpi.purple { border-top-color: var(--purple); }
|
||||||
|
.kpi-label { font-size: 0.64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; color: var(--muted); margin-bottom: 9px; }
|
||||||
|
.kpi-value { font-size: 1.6rem; font-weight: 800; line-height: 1; letter-spacing: -.04em; font-family: 'JetBrains Mono', monospace; }
|
||||||
|
.kpi-value.sm { font-size: 1.15rem; }
|
||||||
|
.kpi-sub { font-size: 0.71rem; color: var(--muted); margin-top: 6px; }
|
||||||
|
|
||||||
|
/* Charts row */
|
||||||
|
.charts { display: grid; grid-template-columns: 1fr 320px; gap: 10px; margin-bottom: 18px; }
|
||||||
|
@media (max-width: 900px) { .charts { grid-template-columns: 1fr; } }
|
||||||
|
.chart-card {
|
||||||
|
background: var(--surface); border-radius: 10px; box-shadow: var(--shadow);
|
||||||
|
padding: 14px 16px 8px;
|
||||||
|
}
|
||||||
|
.chart-card-title {
|
||||||
|
font-size: 0.64rem; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: .09em; color: var(--muted); margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Burn rate bar */
|
||||||
|
.burn-section { background: var(--surface); border-radius: 10px; box-shadow: var(--shadow); padding: 16px 20px; margin-bottom: 18px; }
|
||||||
|
.burn-title { font-size: 0.64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; color: var(--muted); margin-bottom: 14px; }
|
||||||
|
.burn-row { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
|
||||||
|
.burn-label { font-size: 0.75rem; color: var(--muted); width: 130px; flex-shrink: 0; }
|
||||||
|
.burn-bar-bg { flex: 1; height: 8px; background: #f1f5f9; border-radius: 4px; overflow: hidden; }
|
||||||
|
.burn-bar-fg { height: 100%; border-radius: 4px; transition: width .6s ease; }
|
||||||
|
.burn-val { font-size: 0.72rem; font-family: 'JetBrains Mono', monospace; color: var(--text); width: 80px; text-align: right; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Info table */
|
||||||
|
.section { background: var(--surface); border-radius: 10px; box-shadow: var(--shadow); overflow: hidden; }
|
||||||
|
.section-header {
|
||||||
|
padding: 11px 16px; font-size: 0.64rem; font-weight: 700;
|
||||||
|
text-transform: uppercase; letter-spacing: .09em;
|
||||||
|
color: var(--muted); background: #f8fafc;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th { padding: 8px 14px; font-size: 0.63rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); text-align: left; border-bottom: 1px solid var(--border); background: #f8fafc; }
|
||||||
|
td { padding: 10px 14px; font-size: 0.81rem; border-bottom: 1px solid #f1f5f9; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
tr:hover td { background: rgba(37,99,235,.025); }
|
||||||
|
td.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.77rem; }
|
||||||
|
.empty { padding: 24px 16px; font-size: 0.8rem; color: var(--muted); font-style: italic; text-align: center; }
|
||||||
|
|
||||||
|
.footer { font-size: 0.64rem; color: #cbd5e1; text-align: center; margin-top: 32px; letter-spacing: .05em; }
|
||||||
|
.no-data { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 60px 20px; }
|
||||||
|
.no-data-icon { font-size: 2.5rem; }
|
||||||
|
.no-data-text { font-size: 0.88rem; color: var(--muted); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1>Money<span>Maker</span></h1>
|
||||||
|
<span class="header-pill">AI Costs</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-meta">
|
||||||
|
{{ now }}<br>
|
||||||
|
<a class="back-link" href="/">← Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if no_data %}
|
||||||
|
<div class="section">
|
||||||
|
<div class="no-data">
|
||||||
|
<div class="no-data-icon">📊</div>
|
||||||
|
<div class="no-data-text">Ingen data endnu — kør analyze.py med Claude for at begynde at tracke forbrug.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<div class="kpis">
|
||||||
|
<div class="kpi red">
|
||||||
|
<div class="kpi-label">Total Cost</div>
|
||||||
|
<div class="kpi-value{% if total_cost_usd >= 1 %} sm{% endif %}">${{ "%.4f"|format(total_cost_usd) }}</div>
|
||||||
|
<div class="kpi-sub">USD spent</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi blue">
|
||||||
|
<div class="kpi-label">Claude Calls</div>
|
||||||
|
<div class="kpi-value">{{ total_calls }}</div>
|
||||||
|
<div class="kpi-sub">articles analyzed</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi yellow">
|
||||||
|
<div class="kpi-label">Input Tokens</div>
|
||||||
|
<div class="kpi-value sm">{{ "{:,}".format(total_input_tokens) }}</div>
|
||||||
|
<div class="kpi-sub">${{ "%.4f"|format(cost_input) }} · {{ input_pct }}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi purple">
|
||||||
|
<div class="kpi-label">Output Tokens</div>
|
||||||
|
<div class="kpi-value sm">{{ "{:,}".format(total_output_tokens) }}</div>
|
||||||
|
<div class="kpi-sub">${{ "%.4f"|format(cost_output) }} · {{ output_pct }}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi green">
|
||||||
|
<div class="kpi-label">Cost / Article</div>
|
||||||
|
<div class="kpi-value{% if cost_per_call >= 0.01 %} sm{% endif %}">${{ "%.5f"|format(cost_per_call) }}</div>
|
||||||
|
<div class="kpi-sub">avg per Claude call</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="charts">
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-card-title">Token Distribution — Input vs Output</div>
|
||||||
|
<div id="bar-chart" style="height:220px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-card-title">Cost Split</div>
|
||||||
|
<div id="donut-chart" style="height:220px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="burn-section">
|
||||||
|
<div class="burn-title">Token Breakdown</div>
|
||||||
|
<div class="burn-row">
|
||||||
|
<div class="burn-label">Input tokens</div>
|
||||||
|
<div class="burn-bar-bg"><div class="burn-bar-fg" style="width:{{ input_pct }}%; background: var(--yellow);"></div></div>
|
||||||
|
<div class="burn-val">{{ "{:,}".format(total_input_tokens) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="burn-row">
|
||||||
|
<div class="burn-label">Output tokens</div>
|
||||||
|
<div class="burn-bar-bg"><div class="burn-bar-fg" style="width:{{ output_pct }}%; background: var(--purple);"></div></div>
|
||||||
|
<div class="burn-val">{{ "{:,}".format(total_output_tokens) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="burn-row">
|
||||||
|
<div class="burn-label">Total tokens</div>
|
||||||
|
<div class="burn-bar-bg"><div class="burn-bar-fg" style="width:100%; background: var(--accent);"></div></div>
|
||||||
|
<div class="burn-val">{{ "{:,}".format(total_input_tokens + total_output_tokens) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">Model Details</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Metric</th>
|
||||||
|
<th>Value</th>
|
||||||
|
<th>Note</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Model</td><td class="mono">claude-haiku-4-5</td><td>Anthropic</td></tr>
|
||||||
|
<tr><td>Input price</td><td class="mono">$0.80 / MTok</td><td>${{ "%.6f"|format(0.80/1_000_000) }} per token</td></tr>
|
||||||
|
<tr><td>Output price</td><td class="mono">$4.00 / MTok</td><td>${{ "%.6f"|format(4.00/1_000_000) }} per token</td></tr>
|
||||||
|
<tr><td>Total calls</td><td class="mono">{{ total_calls }}</td><td>articles sent to Claude</td></tr>
|
||||||
|
<tr><td>Total input tokens</td><td class="mono">{{ "{:,}".format(total_input_tokens) }}</td><td>cost ${{ "%.5f"|format(cost_input) }}</td></tr>
|
||||||
|
<tr><td>Total output tokens</td><td class="mono">{{ "{:,}".format(total_output_tokens) }}</td><td>cost ${{ "%.5f"|format(cost_output) }}</td></tr>
|
||||||
|
<tr><td>Total cost</td><td class="mono">${{ "%.6f"|format(total_cost_usd) }}</td><td>cumulative</td></tr>
|
||||||
|
<tr><td>Last updated</td><td class="mono">{{ last_updated }}</td><td></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── Bar chart: tokens per type ──────────────────────────────────────────────
|
||||||
|
const barChart = echarts.init(document.getElementById('bar-chart'));
|
||||||
|
barChart.setOption({
|
||||||
|
grid: { top: 24, right: 20, bottom: 28, left: 70 },
|
||||||
|
tooltip: { trigger: 'axis', formatter: p => p[0].name + '<br/>' + p[0].marker + ' ' + p[0].seriesName + ': <b>' + p[0].value.toLocaleString() + '</b>' },
|
||||||
|
xAxis: { type: 'category', data: ['Input', 'Output'], axisLabel: { fontSize: 11 } },
|
||||||
|
yAxis: { type: 'value', axisLabel: { fontSize: 10, formatter: v => v >= 1000 ? (v/1000).toFixed(0)+'k' : v } },
|
||||||
|
series: [{
|
||||||
|
name: 'Tokens',
|
||||||
|
type: 'bar', barMaxWidth: 60,
|
||||||
|
data: [
|
||||||
|
{ value: {{ total_input_tokens }}, itemStyle: { color: '#d97706', borderRadius: [4,4,0,0] } },
|
||||||
|
{ value: {{ total_output_tokens }}, itemStyle: { color: '#7c3aed', borderRadius: [4,4,0,0] } },
|
||||||
|
],
|
||||||
|
label: { show: true, position: 'top', formatter: p => p.value.toLocaleString(), fontSize: 10, color: '#64748b' }
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Donut: cost split ───────────────────────────────────────────────────────
|
||||||
|
const donut = echarts.init(document.getElementById('donut-chart'));
|
||||||
|
donut.setOption({
|
||||||
|
tooltip: { trigger: 'item', formatter: '{b}: ${c} ({d}%)' },
|
||||||
|
legend: { bottom: 0, left: 'center', textStyle: { fontSize: 10, color: '#64748b' } },
|
||||||
|
series: [{
|
||||||
|
type: 'pie', radius: ['42%', '68%'],
|
||||||
|
center: ['50%', '44%'],
|
||||||
|
label: { show: false },
|
||||||
|
emphasis: { label: { show: true, fontSize: 12, fontWeight: 'bold' } },
|
||||||
|
data: [
|
||||||
|
{ value: {{ "%.6f"|format(cost_input) }}, name: 'Input', itemStyle: { color: '#d97706' } },
|
||||||
|
{ value: {{ "%.6f"|format(cost_output) }}, name: 'Output', itemStyle: { color: '#7c3aed' } },
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => { barChart.resize(); donut.resize(); });
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="footer">MoneyMaker · claude-haiku-4-5 · metrics updated on each analyze run</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
# ── HTML template ─────────────────────────────────────────────────────────────
|
# ── HTML template ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
TEMPLATE = """<!DOCTYPE html>
|
TEMPLATE = """<!DOCTYPE html>
|
||||||
@@ -762,6 +1033,69 @@ def health():
|
|||||||
return {"status": "ok", "db": DB_TYPE, "ts": time.time()}
|
return {"status": "ok", "db": DB_TYPE, "ts": time.time()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/metrics")
|
||||||
|
def metrics():
|
||||||
|
data: dict = {}
|
||||||
|
if METRICS_FILE.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(METRICS_FILE.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Total articles with Claude signal from DB
|
||||||
|
try:
|
||||||
|
db = get_conn()
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT COUNT(DISTINCT article_slug) AS cnt FROM article_signals WHERE claude_reasoning != '' AND claude_reasoning IS NOT NULL"
|
||||||
|
).fetchone()
|
||||||
|
data["total_articles_with_claude"] = row[0] if row else 0
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/metrics-dash")
|
||||||
|
def metrics_dash():
|
||||||
|
data: dict = {}
|
||||||
|
if METRICS_FILE.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(METRICS_FILE.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
total_calls = data.get("total_calls", 0)
|
||||||
|
total_input_tokens = data.get("total_input_tokens", 0)
|
||||||
|
total_output_tokens = data.get("total_output_tokens", 0)
|
||||||
|
total_cost_usd = data.get("total_cost_usd", 0.0)
|
||||||
|
last_updated = data.get("last_updated", "—")
|
||||||
|
|
||||||
|
total_tokens = total_input_tokens + total_output_tokens
|
||||||
|
input_pct = round(total_input_tokens / total_tokens * 100) if total_tokens else 0
|
||||||
|
output_pct = round(total_output_tokens / total_tokens * 100) if total_tokens else 0
|
||||||
|
|
||||||
|
cost_input = round(total_input_tokens * 0.80 / 1_000_000, 6)
|
||||||
|
cost_output = round(total_output_tokens * 4.00 / 1_000_000, 6)
|
||||||
|
cost_per_call = round(total_cost_usd / total_calls, 6) if total_calls else 0.0
|
||||||
|
|
||||||
|
return render_template_string(
|
||||||
|
METRICS_TEMPLATE,
|
||||||
|
no_data=total_calls == 0,
|
||||||
|
now=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
|
||||||
|
total_calls=total_calls,
|
||||||
|
total_input_tokens=total_input_tokens,
|
||||||
|
total_output_tokens=total_output_tokens,
|
||||||
|
total_cost_usd=total_cost_usd,
|
||||||
|
last_updated=last_updated,
|
||||||
|
input_pct=input_pct,
|
||||||
|
output_pct=output_pct,
|
||||||
|
cost_input=cost_input,
|
||||||
|
cost_output=cost_output,
|
||||||
|
cost_per_call=cost_per_call,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
Reference in New Issue
Block a user