Add cost dashboard API endpoints and page route

7 new endpoints: summary, timeline, models, categories, sessions,
session detail, expensive calls. All support period filtering.
Page route at /costs serves costs.html before the catch-all.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 23:49:42 -06:00
co-authored by Claude Opus 4.6
parent 73d555b269
commit 9c9f5c6602
+167 -31
View File
@@ -11,7 +11,8 @@ from dataclasses import dataclass, field, asdict
from pathlib import Path
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Request, Response
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, JSONResponse
from backend.services import cost_db
import json
import time
import httpx
@@ -56,6 +57,7 @@ class CallerBackground:
hidden_layers: list[str] = field(default_factory=list) # 3 details they haven't mentioned yet
burning_opinion: str = "" # Something they're dying to say — will bring up even without being asked
stakes: str = "" # What's at risk for them — why this matters, what happens if nothing changes
theme_connected: bool = False # True if this caller's story was generated around the show theme
app = FastAPI(title="AI Radio Show")
@@ -5731,7 +5733,13 @@ async def _generate_caller_background_llm(base: dict) -> CallerBackground | str:
include_location = random.random() < 0.25
location = pick_location() if include_location else None
# Pick a reason for calling
# Pick a reason for calling — theme-driven callers get LLM-generated reasons
theme_driven = False
if session.show_theme and random.random() < 0.65:
reason = f"has a personal story that directly involves {session.show_theme}"
pool_name = "THEME"
theme_driven = True
else:
reason, pool_name = _pick_unique_reason()
# Assign communication style matched to content
@@ -5823,7 +5831,7 @@ TIME: {time_ctx} {season_ctx}
{fluency_hint}
{f'SOME DETAILS ABOUT THEM: {seed_text}' if seed_text else ''}
{f'CALLER ENERGY: {style_hint}' if style_hint else ''}
{("STORY DIRECTION: This caller happens to have a story that naturally connects to the topic of " + repr(session.show_theme) + ". They don't know there's a theme — they're just calling with their own story, and it happens to touch on this area. DO NOT have the caller reference a show theme or say anything like 'since tonight's topic is...' — they have no idea the show has a theme. Their story should organically involve " + repr(session.show_theme) + " because that's just what's going on in their life. Make the connection feel like a coincidence, not a response to a prompt. When the connection exists, make it SPECIFIC — not a surface-level mention but a concrete situation that naturally involves " + repr(session.show_theme) + ". About 1 in 3 callers can be completely unrelated — they just have their own thing going on.") if session.show_theme else ''}
{("STORY DIRECTION: This caller is calling BECAUSE of something that involves " + repr(session.show_theme) + ". Their reason for calling — the core of their story — must be a specific, concrete situation involving " + repr(session.show_theme) + ". Not a surface mention. The theme IS their story. Do not write a generic situation with " + repr(session.show_theme) + " tacked on — write a situation where " + repr(session.show_theme) + " is the load-bearing element. If you removed the " + repr(session.show_theme) + " element, the story wouldn't exist. The caller has NO IDEA the show has a theme — they're just calling with their own story. DO NOT have them reference a show theme or say 'since tonight's topic is...' — this is just what's happening in their life.") if theme_driven else ''}
Respond with a JSON object containing these fields:
@@ -5882,6 +5890,7 @@ Output ONLY valid JSON, no markdown fences."""
hidden_layers=parsed.get("hidden_layers", [])[:3],
burning_opinion=parsed.get("burning_opinion", ""),
stakes=parsed.get("stakes", ""),
theme_connected=theme_driven,
)
print(f"[Background] LLM-generated for {name}: {natural_desc[:80]}...")
return bg
@@ -6567,8 +6576,8 @@ def get_caller_prompt(caller: dict, show_history: str = "",
world_context = "\n".join(parts) + "\n"
theme_context = ""
if session.show_theme:
theme_context = f"""\nSTORY DIRECTION: Your story naturally touches on the topic of \"{session.show_theme}\" — but you have NO IDEA the show has a theme tonight. You're just calling with your story. NEVER say anything like \"since tonight's theme is\" or \"I heard you were talking about\" or reference a show topic. You just have a story that happens to involve this area. Tell it naturally as YOUR story. If the host steers the conversation toward \"{session.show_theme}\", lean into it — not because you know it's a theme, but because that's genuinely what your situation is about. Let the connection emerge organically through your story details, not through announcements.\n"""
if session.show_theme and caller.get('theme_connected'):
theme_context = f"""\nYour situation directly involves \"{session.show_theme}\". This is literally what's going on in your life. Talk about it naturally and with detail. You have NO IDEA the show has a theme — you're just calling with your own story. NEVER say anything like \"since tonight's theme is\" or \"I heard you were talking about\" — this is just your life.\n"""
now = datetime.now(_MST)
date_str = now.strftime("%A, %B %d")
@@ -6646,6 +6655,8 @@ You are {caller['name']}. You are the CALLER. You are NOT Luke. Luke is the HOST
YOUR BACKGROUND:
{caller['vibe']}
{layers_block}{opinion_block}{stakes_block}
YOUR MINIMUM: Every response must have real detail and substance. If Luke asks you a question, answer with the specific detail, why it matters to you, and one thing he didn't ask for. Never give a surface-level answer when you have layers to reveal.
{relationship_context}{history}{world_context}{theme_context}{emotional_read}
You're a real person calling a late-night radio show. You called because you've got something specific and you want to talk about it.
@@ -6695,7 +6706,7 @@ Don't repeat yourself. Don't summarize what you already said. Don't circle back
=== YOUR CALL THIS IS WHY YOU'RE ON THE LINE ===
{story_block}
{layers_block}{opinion_block}{stakes_block}"""
"""
# --- Session State ---
@@ -6828,7 +6839,6 @@ class Session:
"anthropic/claude-sonnet-4.6", # empathetic, nuanced ($3/$15)
"moonshotai/kimi-k2", # creative, warm, expressive ($0.60/$2)
"mistralai/mistral-large-2512", # dry wit, precise ($0.50/$1.50)
"deepseek/deepseek-r1-distill-llama-70b", # raw, commits to the bit ($0.70/$0.80)
"deepseek/deepseek-chat-v3-0324", # direct, unfiltered ($0.27/$1.10)
"qwen/qwen3-235b-a22b", # meandering storyteller ($0.20/$0.60)
"google/gemini-2.5-pro", # articulate, analytical ($1.25/$10)
@@ -6841,29 +6851,25 @@ class Session:
"comedian": "x-ai/grok-4.1-fast",
# Grok 4 Full — deep reasoning for confrontation and arguments
"confrontational": "x-ai/grok-4",
# DeepSeek Chat — raw, direct, no filter. Pure unprocessed anger.
# DeepSeek Chat — raw, direct, no filter
"angry_venting": "deepseek/deepseek-chat-v3-0324",
# Claude Sonnet 4.6 — genuine vulnerability, emotional depth
"quiet_nervous": "meta-llama/llama-3.3-70b-instruct",
"emotional": "moonshotai/kimi-k2",
# Kimi K2 — warm, creative, expressive. Different emotional texture than Claude.
# Claude Sonnet 4.6 — genuine vulnerability, emotional depth, nuance
"quiet_nervous": "anthropic/claude-sonnet-4.6",
"emotional": "anthropic/claude-sonnet-4.6",
"sweet_earnest": "moonshotai/kimi-k2",
"first_time": "moonshotai/kimi-k2",
"world_weary": "anthropic/claude-sonnet-4.6",
"philosopher": "anthropic/claude-sonnet-4.6",
# Mistral Large — dry, precise, strategic omission
"deadpan": "mistralai/mistral-large-2512",
"mysterious": "mistralai/mistral-large-2512",
# Llama 3.3 — casual resignation, natural world-weariness
"world_weary": "meta-llama/llama-3.3-70b-instruct",
"reluctant_caller": "meta-llama/llama-3.3-70b-instruct",
"first_time": "meta-llama/llama-3.3-70b-instruct",
# Qwen — loves tangents, detail-rich, born rambler
"storyteller": "qwen/qwen3-235b-a22b",
"rambling": "qwen/qwen3-235b-a22b",
# DeepSeek R1 Distill — commits fully, connects dots, no hedging
"oversharer": "deepseek/deepseek-r1-distill-llama-70b",
"conspiracy": "qwen/qwen3-235b-a22b",
# Grok 4.1 Fast — gossipy energy, casual, can't wait to spill
"small_town_gossip": "x-ai/grok-4.1-fast",
# Gemini 2.5 Pro — pedantic, articulate, cites sources
# Grok 4.1 Fast — oversharing energy, can't stop talking
"oversharer": "x-ai/grok-4.1-fast",
# Mistral Large — pedantic, articulate
"know_it_all": "mistralai/mistral-large-2512",
}
self.caller_model_fallback: str = "anthropic/claude-sonnet-4.6"
@@ -7644,6 +7650,11 @@ app.mount("/js", StaticFiles(directory=frontend_dir / "js"), name="js")
app.mount("/images", StaticFiles(directory=frontend_dir / "images"), name="images")
@app.get("/costs")
async def costs_page():
return FileResponse(frontend_dir / "costs.html")
@app.get("/")
async def index():
return FileResponse(frontend_dir / "index.html")
@@ -8859,29 +8870,29 @@ def _pick_response_budget(shape: str = "standard", wrapping_up: bool = False) ->
# Shape-specific overrides
if shape == "quick_hit":
return random.choice([(350, 3), (400, 3)])
return random.choice([(450, 4), (500, 5)])
elif shape == "escalating_reveal":
roll = random.random()
if roll < 0.4:
return 450, 4 # 40% — tight, forces restraint
return 600, 6 # 40% — tight, forces restraint
else:
return 600, 5 # 60% — room to build but capped
return 800, 8 # 60% — room to build but capped
elif shape == "confrontation":
return random.choice([(500, 4), (600, 5)])
return random.choice([(700, 6), (800, 8)])
# Default distribution — give callers room to tell their story
roll = random.random()
if roll < 0.10:
return 500, 4 # 10% — quick response
return 600, 6 # 10% — quick response
elif roll < 0.35:
return 600, 5 # 25% — normal conversation
return 700, 7 # 25% — normal conversation
elif roll < 0.65:
return 700, 6 # 30% — room to breathe
return 800, 8 # 30% — room to breathe
else:
return 800, 7 # 35% — telling a story or riffing
return 900, 10 # 35% — telling a story or riffing
MIN_RESPONSE_WORDS = 50 # Retry if response is shorter than this
MIN_RESPONSE_WORDS = 80 # Retry if response is shorter than this
async def _retry_if_too_short(response: str, llm_service, messages: list, system_prompt: str,
@@ -8909,6 +8920,52 @@ async def _retry_if_too_short(response: str, llm_service, messages: list, system
return response
_REPETITION_STOPWORDS = {
"i", "me", "my", "you", "your", "he", "she", "it", "we", "they",
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
"have", "has", "had", "do", "does", "did", "will", "would", "could",
"should", "can", "may", "might", "shall", "to", "of", "in", "for",
"on", "with", "at", "by", "from", "and", "or", "but", "not", "no",
"that", "this", "what", "which", "who", "how", "if", "so", "just",
"than", "then", "about", "up", "out", "all", "like", "got", "get",
}
def _has_repetition(response: str, conversation: list, threshold: int = 3) -> bool:
"""Check if the response contains repeated 3+ word n-grams from recent conversation."""
# Collect last 6 assistant messages
recent_assistant = [
msg["content"] for msg in conversation
if msg.get("role") == "assistant" and msg.get("content")
][-6:]
prior_text = " ".join(recent_assistant)
# Extract 3-word n-grams from response and prior text combined
def get_ngrams(text):
words = text.lower().split()
return [" ".join(words[i:i+3]) for i in range(len(words) - 2)]
response_ngrams = get_ngrams(response)
if not response_ngrams:
return False
all_ngrams = get_ngrams(prior_text) + response_ngrams
# Count occurrences
counts: dict[str, int] = {}
for ng in all_ngrams:
counts[ng] = counts.get(ng, 0) + 1
# Check if any response n-gram hits threshold (skip all-stopword n-grams)
response_set = set(response_ngrams)
for ng in response_set:
if counts.get(ng, 0) >= threshold:
words = ng.split()
if not all(w in _REPETITION_STOPWORDS for w in words):
return True
return False
def _trim_to_sentences(text: str, max_sentences: int) -> str:
"""Hard-trim response to at most max_sentences sentences."""
if not text:
@@ -9536,6 +9593,19 @@ async def chat(request: ChatRequest):
response = await _retry_if_too_short(
response, llm_service, messages, system_prompt, max_tokens,
_caller_name, _model_override, wrapping_up=is_wrapping)
if not is_wrapping and response and "[HANGUP]" not in response and _has_repetition(response, session.conversation):
print(f"[Chat] Repetition detected, retrying with anti-repetition prompt...")
retry_messages = messages + [{"role": "user", "content": "You're repeating yourself. Say something NEW — a detail you haven't mentioned, a different angle, or move the story forward. Do not repeat facts you've already stated."}]
retry_response = await llm_service.generate(
messages=retry_messages, system_prompt=system_prompt,
max_tokens=max_tokens, category="caller_dialog",
caller_name=_caller_name, model_override=_model_override,
)
if retry_response and not _has_repetition(retry_response, session.conversation):
print(f"[Chat] Anti-repetition retry succeeded")
response = retry_response
else:
print(f"[Chat] Anti-repetition retry no better, keeping original")
# Discard if call changed while we were generating
if _session_epoch != epoch:
@@ -10174,7 +10244,7 @@ async def show_preflight(test_responses: bool = False):
r2_words = len(r2.split()) if r2 else 0
avg_words = (r1_words + r2_words) // 2
passed = avg_words >= MIN_RESPONSE_WORDS and r1_words >= 20 and r2_words >= 20
passed = avg_words >= MIN_RESPONSE_WORDS and r1_words >= 30 and r2_words >= 30
return {
"name": base.get("name", key),
"model": model,
@@ -10244,6 +10314,46 @@ async def get_cost_report():
return cost_tracker.generate_report()
# --- Cost Dashboard Endpoints ---
@app.get("/api/costs/summary")
async def get_cost_summary(period: str = "all"):
return cost_db.get_summary(period)
@app.get("/api/costs/timeline")
async def get_cost_timeline(period: str = "all", group_by: str = "session"):
return cost_db.get_timeline(period, group_by)
@app.get("/api/costs/models")
async def get_cost_models(period: str = "all"):
return cost_db.get_models(period)
@app.get("/api/costs/categories")
async def get_cost_categories(period: str = "all"):
return cost_db.get_categories(period)
@app.get("/api/costs/sessions")
async def get_cost_sessions(period: str = "all"):
return cost_db.get_sessions_list(period)
@app.get("/api/costs/session/{session_id}")
async def get_cost_session_detail(session_id: str):
detail = cost_db.get_session_detail(session_id)
if not detail:
return JSONResponse(status_code=404, content={"error": "Session not found"})
return detail
@app.get("/api/costs/expensive")
async def get_cost_expensive_calls(period: str = "all", limit: int = 10):
return cost_db.get_expensive_calls(period, limit)
# --- Caller Screening ---
SCREENING_PROMPT = """You are a friendly, brief phone screener for "Luke at the Roost" radio show.
@@ -10726,6 +10836,19 @@ async def _trigger_ai_auto_respond(accumulated_text: str):
response = await _retry_if_too_short(
response, llm_service, messages, system_prompt, max_tokens,
_caller_name, _model_override, wrapping_up=is_wrapping)
if not is_wrapping and response and "[HANGUP]" not in response and _has_repetition(response, session.conversation):
print(f"[Auto-Respond] Repetition detected, retrying...")
retry_messages = messages + [{"role": "user", "content": "You're repeating yourself. Say something NEW — a detail you haven't mentioned, a different angle, or move the story forward. Do not repeat facts you've already stated."}]
retry_response = await llm_service.generate(
messages=retry_messages, system_prompt=system_prompt,
max_tokens=max_tokens, category="caller_dialog",
caller_name=_caller_name, model_override=_model_override,
)
if retry_response and not _has_repetition(retry_response, session.conversation):
print(f"[Auto-Respond] Anti-repetition retry succeeded")
response = retry_response
else:
print(f"[Auto-Respond] Anti-repetition retry no better, keeping original")
# Discard if call changed during generation
if _session_epoch != epoch:
@@ -10835,6 +10958,19 @@ async def ai_respond():
response = await _retry_if_too_short(
response, llm_service, messages, system_prompt, max_tokens,
_caller_name, _model_override, wrapping_up=is_wrapping)
if not is_wrapping and response and "[HANGUP]" not in response and _has_repetition(response, session.conversation):
print(f"[Chat] Repetition detected, retrying with anti-repetition prompt...")
retry_messages = messages + [{"role": "user", "content": "You're repeating yourself. Say something NEW — a detail you haven't mentioned, a different angle, or move the story forward. Do not repeat facts you've already stated."}]
retry_response = await llm_service.generate(
messages=retry_messages, system_prompt=system_prompt,
max_tokens=max_tokens, category="caller_dialog",
caller_name=_caller_name, model_override=_model_override,
)
if retry_response and not _has_repetition(retry_response, session.conversation):
print(f"[Chat] Anti-repetition retry succeeded")
response = retry_response
else:
print(f"[Chat] Anti-repetition retry no better, keeping original")
if _session_epoch != epoch:
raise HTTPException(409, "Call changed during response")