Track design and implementation plans
Seven plan documents covering the website JS infrastructure, show theme, caller quality overhaul, cost dashboard, the Alpine relocation, and caller variety plus Devon's search — the record of why the current architecture looks the way it does. Plus the press release draft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,719 @@
|
|||||||
|
# Website JS Infrastructure, SEO, Shared Components & Content Fixes
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Fix JS duplication, add worker-level social meta injection, standardize analytics proxying, improve security and UX, and clean up content/SEO issues across lukeattheroost.com.
|
||||||
|
|
||||||
|
**Architecture:** Extract shared footer into `js/footer.js`, extract shared audio player into `js/player.js`, enhance `_worker.js` to intercept social crawler requests and inject episode-specific meta tags, switch all subpages to proxied Plausible analytics, add episode pagination, fix XSS surfaces, and clean up sitemap/clips data.
|
||||||
|
|
||||||
|
**Tech Stack:** Vanilla JS, Cloudflare Pages Worker (ES module), static HTML, XML sitemap
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Create shared footer component (`js/footer.js`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `website/js/footer.js`
|
||||||
|
|
||||||
|
**Step 1: Write footer.js**
|
||||||
|
|
||||||
|
The footer HTML is duplicated across 7 pages (index.html:265-306, episode.html:95-136, clips.html:68-109, stats.html, privacy.html, terms.html, how-it-works.html). Extract the footer from `index.html` as the canonical version.
|
||||||
|
|
||||||
|
```js
|
||||||
|
function initFooter() {
|
||||||
|
const footer = document.querySelector('.footer');
|
||||||
|
if (!footer) return;
|
||||||
|
|
||||||
|
footer.innerHTML = `
|
||||||
|
<div class="footer-nav">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/how-it-works">How It Works</a>
|
||||||
|
<a href="/clips">Clips</a>
|
||||||
|
<a href="/stats">Stats</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer-icons">
|
||||||
|
<span class="footer-icons-label">Listen On</span>
|
||||||
|
<div class="footer-icons-row">
|
||||||
|
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Spotify"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg></a>
|
||||||
|
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener" class="footer-icon-link" aria-label="YouTube"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814z"/><path d="M9.545 15.568V8.432L15.818 12z" fill="#1a1209"/></svg></a>
|
||||||
|
<a href="https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Apple Podcasts"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.477 2 2 6.477 2 12c0 3.293 1.592 6.214 4.05 8.04.13-.455.283-.942.457-1.393A9 9 0 0 1 3 12a9 9 0 0 1 18 0 9 9 0 0 1-3.507 7.127c.174.42.327.893.456 1.333A10 10 0 0 0 22 12c0-5.523-4.477-10-10-10zm0 4a6 6 0 0 0-6 6c0 1.87.856 3.54 2.2 4.64.196-.46.43-.91.692-1.31A4.5 4.5 0 0 1 7.5 12a4.5 4.5 0 0 1 9 0c0 1.21-.478 2.31-1.256 3.12.24.37.462.8.655 1.24A6 6 0 0 0 18 12a6 6 0 0 0-6-6zm0 4.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM12 15c-.75 0-1.158.54-1.28 1.2-.17.94-.28 1.91-.33 2.88-.03.48.34.82.73.82h1.76c.39 0 .76-.34.73-.82-.05-.97-.16-1.94-.33-2.88-.122-.66-.53-1.2-1.28-1.2z"/></svg></a>
|
||||||
|
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener" class="footer-icon-link" aria-label="RSS"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.503 20.752c0 1.794-1.456 3.248-3.251 3.248S0 22.546 0 20.752s1.456-3.248 3.252-3.248 3.251 1.454 3.251 3.248zM.002 9.473v4.594c5.508.163 9.929 4.584 10.092 10.091h4.594C14.524 16.21 7.849 9.636.002 9.473zM.006 0v4.604C10.81 4.77 19.23 13.19 19.396 24h4.604C23.834 10.952 13.054.166.006 0z"/></svg></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-icons">
|
||||||
|
<span class="footer-icons-label">Follow</span>
|
||||||
|
<div class="footer-icons-row">
|
||||||
|
<a href="https://discord.gg/5CnQZxDM" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Discord"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg></a>
|
||||||
|
<a href="https://www.facebook.com/profile.php?id=61588191627949" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Facebook"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg></a>
|
||||||
|
<a href="https://www.instagram.com/lukeattheroost/" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Instagram"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z"/></svg></a>
|
||||||
|
<a href="https://x.com/lukeattheroost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="X"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg></a>
|
||||||
|
<a href="https://bsky.app/profile/lukeattheroost.bsky.social" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Bluesky"><svg viewBox="0 0 568 501" fill="currentColor"><path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C10.945 203.659 1 75.291 1 57.946 1-28.906 76.134-1.612 123.121 33.664z"/></svg></a>
|
||||||
|
<a href="https://mastodon.macneilmediagroup.com/@lukeattheroost" target="_blank" rel="me noopener" class="footer-icon-link" aria-label="Mastodon"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054 19.648 19.648 0 0 0 4.636.528c.164 0 .329 0 .494-.002 1.694-.042 3.48-.152 5.12-.554 2.21-.543 4.137-2.186 4.348-4.55.162-1.808.21-3.627.142-5.43-.02-.6-.168-1.874-.168-1.874z"/><path d="M19.903 7.515v5.834c0 1.226-.996 2.222-2.222 2.222h-.796c-1.226 0-2.222-.996-2.222-2.222V7.628c0-1.226.996-2.222 2.222-2.222h.796c.122 0 .242.01.36.03 1.076.164 1.862 1.098 1.862 2.192zM9.337 7.515v5.834c0 1.226-.996 2.222-2.222 2.222h-.796c-1.226 0-2.222-.996-2.222-2.222V7.628c0-1.226.996-2.222 2.222-2.222h.796c.122 0 .242.01.36.03 1.076.164 1.862 1.098 1.862 2.192z" fill="#1a1209"/></svg></a>
|
||||||
|
<a href="https://primal.net/p/nprofile1qqswsam9cx06j7sxzpl498uquk3kgrwedxtq48j57zxkuj8fs82xtugge0wtg" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Nostr"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.186.31a.27.27 0 0 0-.372 0C8.46 3.487 2.666 9.93 2.666 15.042c0 5.176 4.183 8.958 9.334 8.958s9.334-3.782 9.334-8.958c0-5.112-5.794-11.555-9.148-14.732z"/></svg></a>
|
||||||
|
<a href="https://www.threads.com/@lukeattheroost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Threads"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.59 12c.025 3.086.718 5.496 2.057 7.164 1.432 1.781 3.632 2.695 6.54 2.717 2.227-.017 4.048-.59 5.413-1.703 1.428-1.163 2.076-2.645 1.925-4.403-.098-1.13-.578-2.065-1.39-2.7-.811-.636-1.905-.993-3.164-1.033a11.253 11.253 0 0 0-.04 0c-1.078.007-2.044.289-2.79.816-.68.481-1.069 1.108-1.125 1.813-.057.72.264 1.32.877 1.64.554.29 1.317.437 2.271.437l.013-.001c.652-.004 1.383-.078 2.172-.218l.386 2.022c-.947.18-1.837.273-2.643.278a10.35 10.35 0 0 1-.143 0c-1.425-.013-2.657-.284-3.66-.804-1.237-.643-1.928-1.745-1.836-2.93.099-1.258.738-2.316 1.849-3.064 1.088-.732 2.466-1.12 3.988-1.124h.05c1.644.044 3.088.528 4.178 1.398 1.133.905 1.8 2.185 1.935 3.703.2 2.258-.697 4.2-2.598 5.75-1.668 1.36-3.863 2.087-6.348 2.105z"/></svg></a>
|
||||||
|
<a href="https://www.linkedin.com/company/luke-at-the-roost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="LinkedIn"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg></a>
|
||||||
|
<a href="https://www.tiktok.com/@luke.at.the.roost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="TikTok"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-projects">
|
||||||
|
<span class="footer-projects-label">More from Luke</span>
|
||||||
|
<div class="footer-projects-links">
|
||||||
|
<a href="https://macneilmediagroup.com" target="_blank" rel="noopener">MacNeil Media Group</a>
|
||||||
|
<a href="https://prints.macneilmediagroup.com" target="_blank" rel="noopener">Photography Prints</a>
|
||||||
|
<a href="https://youtube.com/lukemacneil" target="_blank" rel="noopener">YouTube</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
|
||||||
|
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@lukeattheroost.com">luke@lukeattheroost.com</a></p>
|
||||||
|
<p>© 2026 Luke at the Roost · <a href="/privacy">Privacy Policy</a> · <a href="/terms">Terms of Service</a> · <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
initFooter();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/js/footer.js
|
||||||
|
git commit -m "Add shared footer component (js/footer.js)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Replace inline footers with shared component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/index.html` — replace lines 265-306 (inline footer content) with empty `<footer class="footer"></footer>`, add `<script src="js/footer.js"></script>` before closing `</body>`
|
||||||
|
- Modify: `website/episode.html` — replace lines 95-136 with empty footer, add script tag
|
||||||
|
- Modify: `website/clips.html` — replace lines 68-109 with empty footer, add script tag
|
||||||
|
- Modify: `website/stats.html` — replace inline footer with empty footer, add script tag
|
||||||
|
- Modify: `website/privacy.html` — replace inline footer with empty footer, add script tag
|
||||||
|
- Modify: `website/terms.html` — replace inline footer with empty footer, add script tag
|
||||||
|
- Modify: `website/how-it-works.html` — replace inline footer with empty footer, add script tag
|
||||||
|
|
||||||
|
**Step 1: Update each page**
|
||||||
|
|
||||||
|
For each of the 7 HTML files:
|
||||||
|
1. Replace the entire `<footer class="footer">...</footer>` block with just `<footer class="footer"></footer>`
|
||||||
|
2. Add `<script src="js/footer.js"></script>` near the end of `<body>`, before any page-specific scripts
|
||||||
|
|
||||||
|
Note: index.html's footer has slightly different nav links (no "Home" link since it IS home). The shared footer includes "Home" which is fine — clicking Home on the homepage just reloads it.
|
||||||
|
|
||||||
|
**Step 2: Verify no footer content remains inline**
|
||||||
|
|
||||||
|
Search for `footer-icons-label` in all HTML files — should only appear in `js/footer.js`.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/index.html website/episode.html website/clips.html website/stats.html website/privacy.html website/terms.html website/how-it-works.html
|
||||||
|
git commit -m "Replace inline footers with shared footer.js component"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Extract shared audio player module (`js/player.js`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `website/js/player.js`
|
||||||
|
|
||||||
|
The audio player code is duplicated: `app.js:1-11,14-23,143-226` and `episode.html:159-346` (inline `<script>`). Extract the shared player logic.
|
||||||
|
|
||||||
|
**Step 1: Write player.js**
|
||||||
|
|
||||||
|
```js
|
||||||
|
const audio = document.getElementById('audio-element');
|
||||||
|
const stickyPlayer = document.getElementById('sticky-player');
|
||||||
|
const playerPlayBtn = document.getElementById('player-play-btn');
|
||||||
|
const playerTitle = document.getElementById('player-title');
|
||||||
|
const playerProgress = document.getElementById('player-progress');
|
||||||
|
const playerProgressFill = document.getElementById('player-progress-fill');
|
||||||
|
const playerTime = document.getElementById('player-time');
|
||||||
|
|
||||||
|
function formatTime(seconds) {
|
||||||
|
if (!seconds || isNaN(seconds)) return '0:00';
|
||||||
|
const s = Math.floor(seconds);
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
||||||
|
return `${m}:${String(sec).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePlayIcons(playing) {
|
||||||
|
const iconPlay = playerPlayBtn.querySelector('.icon-play');
|
||||||
|
const iconPause = playerPlayBtn.querySelector('.icon-pause');
|
||||||
|
if (iconPlay) iconPlay.style.display = playing ? 'none' : 'block';
|
||||||
|
if (iconPause) iconPause.style.display = playing ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.addEventListener('play', () => updatePlayIcons(true));
|
||||||
|
audio.addEventListener('pause', () => updatePlayIcons(false));
|
||||||
|
audio.addEventListener('ended', () => updatePlayIcons(false));
|
||||||
|
audio.addEventListener('timeupdate', () => {
|
||||||
|
if (audio.duration) {
|
||||||
|
playerProgressFill.style.width = (audio.currentTime / audio.duration * 100) + '%';
|
||||||
|
playerTime.textContent = `${formatTime(audio.currentTime)} / ${formatTime(audio.duration)}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
playerPlayBtn.addEventListener('click', () => {
|
||||||
|
if (audio.src) { audio.paused ? audio.play() : audio.pause(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
playerProgress.addEventListener('click', (e) => {
|
||||||
|
if (audio.duration) {
|
||||||
|
const rect = playerProgress.getBoundingClientRect();
|
||||||
|
audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/js/player.js
|
||||||
|
git commit -m "Extract shared audio player module (js/player.js)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Refactor app.js and episode.html to use player.js
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/js/app.js` — remove duplicated player code (element lookups, formatTime, audio event listeners, updatePlayIcons, playerPlayBtn click, playerProgress click). Keep: FEED_URL, episode-specific logic (fetchEpisodes, renderEpisodes, playEpisode with card-specific icon toggling), formatDate, parseDuration, truncate, testimonials, on-air.
|
||||||
|
- Modify: `website/episode.html` — remove inline `<script>` block (lines 159-347), replace with `<script src="js/player.js"></script>` then `<script src="js/episode.js"></script>`
|
||||||
|
- Create: `website/js/episode.js` — episode-specific logic extracted from episode.html inline script (load episode from RSS, populate header, transcript loading, play button)
|
||||||
|
- Modify: `website/index.html` — add `<script src="js/player.js"></script>` before `app.js`
|
||||||
|
|
||||||
|
**Step 1: Refactor app.js**
|
||||||
|
|
||||||
|
Remove from app.js:
|
||||||
|
- Lines 1-11 (element lookups — now in player.js)
|
||||||
|
- Lines 14-23 (formatTime — now in player.js)
|
||||||
|
- Lines 173-226 (audio event listeners, updatePlayIcons, playerPlayBtn click, playerProgress click — now in player.js)
|
||||||
|
|
||||||
|
Keep the `currentEpisodeCard` variable and the card-specific icon toggling in `updatePlayIcons`. Since player.js handles the sticky player icons, app.js only needs to handle the episode card icons. Add a listener:
|
||||||
|
|
||||||
|
```js
|
||||||
|
audio.addEventListener('play', () => {
|
||||||
|
if (currentEpisodeCard) {
|
||||||
|
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
|
||||||
|
if (btn) { btn.innerHTML = pauseSVG; btn.classList.add('playing'); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
audio.addEventListener('pause', () => {
|
||||||
|
if (currentEpisodeCard) {
|
||||||
|
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
|
||||||
|
if (btn) { btn.innerHTML = playSVG; btn.classList.remove('playing'); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
audio.addEventListener('ended', () => {
|
||||||
|
if (currentEpisodeCard) {
|
||||||
|
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
|
||||||
|
if (btn) { btn.innerHTML = playSVG; btn.classList.remove('playing'); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Create episode.js**
|
||||||
|
|
||||||
|
Extract episode-specific logic from episode.html inline script. Use the global `audio`, `playerTitle`, `stickyPlayer` from player.js. Include: `formatDate`, `parseDuration`, `stripHtml`, slug parsing, `loadEpisode()`.
|
||||||
|
|
||||||
|
**Step 3: Update HTML script tags**
|
||||||
|
|
||||||
|
In `index.html`, change script loading order:
|
||||||
|
```html
|
||||||
|
<script src="js/footer.js"></script>
|
||||||
|
<script src="js/clips.js"></script>
|
||||||
|
<script>renderFeaturedClipsInline('home-clips');</script>
|
||||||
|
<script src="js/player.js"></script>
|
||||||
|
<script src="js/app.js?v=3"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
In `episode.html`, replace inline `<script>` (lines 159-347) with:
|
||||||
|
```html
|
||||||
|
<script src="js/footer.js"></script>
|
||||||
|
<script src="js/player.js"></script>
|
||||||
|
<script src="js/episode.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/js/app.js website/js/player.js website/js/episode.js website/index.html website/episode.html
|
||||||
|
git commit -m "Deduplicate audio player code into shared player.js module"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Fix Plausible analytics — switch all subpages to proxied version
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/episode.html` line 51-52
|
||||||
|
- Modify: `website/clips.html` line 43-44
|
||||||
|
- Modify: `website/stats.html` line 43-44
|
||||||
|
- Modify: `website/privacy.html` line 37-38
|
||||||
|
- Modify: `website/terms.html` line 37-38
|
||||||
|
- Modify: `website/how-it-works.html` line 66-67
|
||||||
|
|
||||||
|
**Step 1: In each file, replace the direct Plausible script tag**
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
```html
|
||||||
|
<script defer data-domain="lukeattheroost.com" src="https://plausible.macneilmediagroup.com/js/script.file-downloads.hash.outbound-links.pageview-props.revenue.tagged-events.js"></script>
|
||||||
|
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
```html
|
||||||
|
<script defer data-domain="lukeattheroost.com" data-api="/p/event" src="/p/script"></script>
|
||||||
|
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify**
|
||||||
|
|
||||||
|
Grep for `plausible.macneilmediagroup.com` in HTML files — should return 0 matches (only `_worker.js` should have it).
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/episode.html website/clips.html website/stats.html website/privacy.html website/terms.html website/how-it-works.html
|
||||||
|
git commit -m "Switch all subpages to proxied Plausible analytics"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Worker — social crawler meta tag injection for episode pages
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/_worker.js`
|
||||||
|
|
||||||
|
**Step 1: Add social crawler detection and meta injection**
|
||||||
|
|
||||||
|
Before the `return env.ASSETS.fetch(request)` line (line 90), add a handler for `/episode.html` requests from social crawlers:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Social crawler meta injection for episode pages
|
||||||
|
if (url.pathname === "/episode.html" && url.searchParams.get("slug")) {
|
||||||
|
const ua = (request.headers.get("User-Agent") || "").toLowerCase();
|
||||||
|
const isCrawler = /facebookexternalhit|twitterbot|linkedinbot|slackbot|discordbot|telegrambot|whatsapp|pinterest|redditbot/i.test(ua);
|
||||||
|
|
||||||
|
if (isCrawler) {
|
||||||
|
const slug = url.searchParams.get("slug");
|
||||||
|
|
||||||
|
// Fetch RSS to find episode info
|
||||||
|
try {
|
||||||
|
const feedResp = await fetch("https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml", {
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (feedResp.ok) {
|
||||||
|
const feedXml = await feedResp.text();
|
||||||
|
|
||||||
|
// Simple string-based extraction (no DOM parser in Workers)
|
||||||
|
const items = feedXml.split("<item>");
|
||||||
|
let title = "";
|
||||||
|
let description = "";
|
||||||
|
|
||||||
|
for (let i = 1; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
const linkMatch = item.match(/<link>(.*?)<\/link>/);
|
||||||
|
if (linkMatch) {
|
||||||
|
const itemSlug = linkMatch[1].split("/episodes/").pop()?.replace(/\/$/, "");
|
||||||
|
if (itemSlug === slug) {
|
||||||
|
const titleMatch = item.match(/<title>(.*?)<\/title>/);
|
||||||
|
title = titleMatch ? titleMatch[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim() : "";
|
||||||
|
const descMatch = item.match(/<description>(.*?)<\/description>/s);
|
||||||
|
description = descMatch
|
||||||
|
? descMatch[1].replace(/<!\[CDATA\[|\]\]>/g, "").replace(/<[^>]+>/g, "").trim().slice(0, 200)
|
||||||
|
: "";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
// Fetch the actual HTML page
|
||||||
|
const pageResp = await env.ASSETS.fetch(request);
|
||||||
|
let html = await pageResp.text();
|
||||||
|
|
||||||
|
const escTitle = title.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
||||||
|
const escDesc = description.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
||||||
|
const canonicalUrl = `https://lukeattheroost.com/episode.html?slug=${slug}`;
|
||||||
|
|
||||||
|
// Replace placeholder meta tags
|
||||||
|
html = html.replace(
|
||||||
|
/<meta property="og:title"[^>]*>/,
|
||||||
|
`<meta property="og:title" content="${escTitle}">`
|
||||||
|
);
|
||||||
|
html = html.replace(
|
||||||
|
/<meta property="og:description"[^>]*>/,
|
||||||
|
`<meta property="og:description" content="${escDesc}">`
|
||||||
|
);
|
||||||
|
html = html.replace(
|
||||||
|
/<meta property="og:url"[^>]*>/,
|
||||||
|
`<meta property="og:url" content="${canonicalUrl}">`
|
||||||
|
);
|
||||||
|
html = html.replace(
|
||||||
|
/<meta name="twitter:title"[^>]*>/,
|
||||||
|
`<meta name="twitter:title" content="${escTitle}">`
|
||||||
|
);
|
||||||
|
html = html.replace(
|
||||||
|
/<meta name="twitter:description"[^>]*>/,
|
||||||
|
`<meta name="twitter:description" content="${escDesc}">`
|
||||||
|
);
|
||||||
|
html = html.replace(
|
||||||
|
/<title[^>]*>.*?<\/title>/,
|
||||||
|
`<title>${escTitle} — Luke at the Roost</title>`
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(html, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "text/html;charset=UTF-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fall through to static page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/_worker.js
|
||||||
|
git commit -m "Add social crawler meta tag injection for episode pages"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Security — sanitize innerHTML XSS surfaces
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/js/episode.js` (created in Task 4)
|
||||||
|
- Modify: `website/js/app.js`
|
||||||
|
|
||||||
|
**Step 1: Fix episode description XSS in episode.js**
|
||||||
|
|
||||||
|
In the `loadEpisode` function, change line that sets description:
|
||||||
|
```js
|
||||||
|
// BEFORE (XSS):
|
||||||
|
document.getElementById('ep-desc').innerHTML = episode.description || '';
|
||||||
|
|
||||||
|
// AFTER (safe):
|
||||||
|
document.getElementById('ep-desc').textContent = stripHtml(episode.description || '');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Fix title escaping in app.js episode card rendering**
|
||||||
|
|
||||||
|
In `renderEpisodes()`, the title goes into a `data-title` attribute with basic `.replace(/"/g, '"')`. Use the `escapeHTML` pattern from clips.js. Add a helper at top of app.js:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function escapeAttr(str) {
|
||||||
|
return str.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then change line 125:
|
||||||
|
```js
|
||||||
|
// BEFORE:
|
||||||
|
data-title="${ep.title.replace(/"/g, '"')}"
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
data-title="${escapeAttr(ep.title)}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Also escape the title in the aria-label and visible title:
|
||||||
|
```js
|
||||||
|
<button class="episode-play-btn" aria-label="Play ${escapeAttr(ep.title)}" data-url="${escapeAttr(ep.audioUrl)}" data-title="${escapeAttr(ep.title)}">
|
||||||
|
```
|
||||||
|
|
||||||
|
And escape the visible title output:
|
||||||
|
```js
|
||||||
|
<div class="episode-title">${escapeAttr(ep.title)}</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/js/episode.js website/js/app.js
|
||||||
|
git commit -m "Fix XSS: sanitize innerHTML and improve attribute escaping"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Episode pagination — show 10, load more
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/js/app.js`
|
||||||
|
|
||||||
|
**Step 1: Modify renderEpisodes to support pagination**
|
||||||
|
|
||||||
|
```js
|
||||||
|
const EPISODES_PER_PAGE = 10;
|
||||||
|
let allEpisodes = [];
|
||||||
|
let displayedCount = 0;
|
||||||
|
|
||||||
|
function renderEpisodes(episodes) {
|
||||||
|
allEpisodes = episodes;
|
||||||
|
displayedCount = 0;
|
||||||
|
episodesList.innerHTML = '';
|
||||||
|
showMoreEpisodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMoreEpisodes() {
|
||||||
|
const batch = allEpisodes.slice(displayedCount, displayedCount + EPISODES_PER_PAGE);
|
||||||
|
batch.forEach((ep) => {
|
||||||
|
// ... existing card creation code ...
|
||||||
|
episodesList.appendChild(card);
|
||||||
|
});
|
||||||
|
displayedCount += batch.length;
|
||||||
|
|
||||||
|
// Remove existing load-more button if present
|
||||||
|
const existing = document.getElementById('load-more-btn');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
// Add load-more button if there are remaining episodes
|
||||||
|
if (displayedCount < allEpisodes.length) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'load-more-btn';
|
||||||
|
btn.className = 'load-more-btn';
|
||||||
|
btn.textContent = `Load More (${allEpisodes.length - displayedCount} remaining)`;
|
||||||
|
btn.addEventListener('click', showMoreEpisodes);
|
||||||
|
episodesList.after(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: The `.load-more-btn` CSS class likely needs to be created by the ui-ux task. For now, add minimal inline styling if the class doesn't exist yet. Actually, since we're told not to touch CSS, just use the class name and it will be styled later.
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/js/app.js
|
||||||
|
git commit -m "Add episode pagination with Load More button"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Truncate at word boundaries
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/js/app.js`
|
||||||
|
|
||||||
|
**Step 1: Fix the truncate function**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// BEFORE (line 47-53):
|
||||||
|
function truncate(html, maxLen) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = html || '';
|
||||||
|
const text = div.textContent || '';
|
||||||
|
if (text.length <= maxLen) return text;
|
||||||
|
return text.slice(0, maxLen).trimEnd() + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
function truncate(html, maxLen) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = html || '';
|
||||||
|
const text = div.textContent || '';
|
||||||
|
if (text.length <= maxLen) return text;
|
||||||
|
const truncated = text.slice(0, maxLen);
|
||||||
|
const lastSpace = truncated.lastIndexOf(' ');
|
||||||
|
return (lastSpace > maxLen * 0.5 ? truncated.slice(0, lastSpace) : truncated).trimEnd() + '...';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `lastSpace > maxLen * 0.5` guard ensures we don't cut too aggressively if the word boundary is very early.
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/js/app.js
|
||||||
|
git commit -m "Fix truncate to break at word boundaries"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: Deduplicate featured clips on clips page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/js/clips.js`
|
||||||
|
|
||||||
|
**Step 1: Fix initClipsPage to exclude featured from "All Clips" grid**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// BEFORE (line 73-77):
|
||||||
|
if (gridContainer) {
|
||||||
|
clips.forEach(clip => {
|
||||||
|
gridContainer.appendChild(renderClipCard(clip, false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
if (gridContainer) {
|
||||||
|
clips.filter(c => !c.featured).forEach(clip => {
|
||||||
|
gridContainer.appendChild(renderClipCard(clip, false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/js/clips.js
|
||||||
|
git commit -m "Deduplicate featured clips from All Clips grid"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 11: Fix content issues — empty clip description and duplicate sitemap entry
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/data/clips.json` — episode 31 clip (line 58): add description
|
||||||
|
- Modify: `website/sitemap.xml` — remove duplicate episode 32 entry (lines 237-242)
|
||||||
|
|
||||||
|
**Step 1: Add description for episode 31 clip**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Started a Fight and Can't Stop Reading About Wars",
|
||||||
|
"description": "A caller starts a fight with their partner and now can't stop obsessively reading about historical wars. Luke tries to unpack the connection.",
|
||||||
|
"episode_number": 31,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Remove duplicate sitemap entry**
|
||||||
|
|
||||||
|
Remove lines 237-242 (the `episode-32-tacos-taxes-and-tense-conversations` entry). Keep `episode-32-tacos-taxes-and-tall-tales` (lines 231-236) as the canonical one, OR check RSS feed to determine which slug is correct. If both exist in the feed, keep both — but episode numbering suggests one is a duplicate/rename. Remove the second one (`tense-conversations` variant).
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/data/clips.json website/sitemap.xml
|
||||||
|
git commit -m "Fix empty clip description and remove duplicate sitemap entry"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 12: Create custom 404 page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `website/404.html`
|
||||||
|
|
||||||
|
**Step 1: Write 404.html**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Page Not Found — Luke at the Roost</title>
|
||||||
|
<meta name="description" content="The page you're looking for doesn't exist.">
|
||||||
|
<meta name="theme-color" content="#1a1209">
|
||||||
|
<link rel="icon" href="favicon.ico" sizes="48x48">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
|
<link rel="stylesheet" href="css/style.css?v=3">
|
||||||
|
<script defer data-domain="lukeattheroost.com" data-api="/p/event" src="/p/script"></script>
|
||||||
|
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="page-nav">
|
||||||
|
<a href="/" class="nav-home">Luke at the Roost</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="page-header">
|
||||||
|
<h1>404 — Page Not Found</h1>
|
||||||
|
<p class="page-subtitle">Looks like this page wandered off into the desert.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="about-section">
|
||||||
|
<p>The page you're looking for doesn't exist or may have been moved.</p>
|
||||||
|
<p><a href="/">Back to the show</a> · <a href="/clips">Watch clips</a> · <a href="/how-it-works">How it works</a></p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer"></footer>
|
||||||
|
<script src="js/footer.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/404.html
|
||||||
|
git commit -m "Add custom 404 page"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 13: Enhance llms.txt
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/llms.txt`
|
||||||
|
|
||||||
|
**Step 1: Add episode listing section and structured links**
|
||||||
|
|
||||||
|
Add after the FAQ section:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Recent Episodes
|
||||||
|
|
||||||
|
Episodes are published daily. Each has a full transcript available at:
|
||||||
|
https://lukeattheroost.com/episode.html?slug=EPISODE-SLUG
|
||||||
|
|
||||||
|
Episode transcript URLs follow the pattern: episode-N-title-slug
|
||||||
|
Example: https://lukeattheroost.com/episode.html?slug=episode-37-secrets-lies-and-coffee-runs
|
||||||
|
|
||||||
|
## Clip Highlights
|
||||||
|
|
||||||
|
Popular clips with video:
|
||||||
|
- "I Faked Cancer to Skip a Wedding" (Episode 32) — https://youtube.com/watch?v=NUkhsPfMx9o
|
||||||
|
- "Neighbor's Roomba Breaks Into Kitchen at 2:30 AM" (Episode 26) — https://youtube.com/watch?v=J7bfT6jsykA
|
||||||
|
- "Shopping Cart Theory: Moral Test or Crazy?" (Episode 21) — https://youtube.com/watch?v=KijyJsMZfkA
|
||||||
|
|
||||||
|
## Sitemap
|
||||||
|
|
||||||
|
Full sitemap: https://lukeattheroost.com/sitemap.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/llms.txt
|
||||||
|
git commit -m "Enhance llms.txt with episode patterns and clip highlights"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Order & Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
Task 1 (footer.js) — no deps
|
||||||
|
Task 3 (player.js) — no deps
|
||||||
|
Task 5 (analytics fix) — no deps
|
||||||
|
Task 9 (truncate fix) — no deps
|
||||||
|
Task 10 (clips dedup) — no deps
|
||||||
|
Task 11 (content fixes) — no deps
|
||||||
|
Task 12 (404 page) — depends on Task 1 (uses footer.js)
|
||||||
|
Task 13 (llms.txt) — no deps
|
||||||
|
|
||||||
|
Task 2 (replace footers) — depends on Task 1
|
||||||
|
Task 4 (refactor to use player.js) — depends on Task 3
|
||||||
|
Task 6 (worker meta injection) — no deps
|
||||||
|
Task 7 (security fixes) — depends on Task 4 (episode.js must exist)
|
||||||
|
Task 8 (pagination) — can run anytime, modifies app.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parallel batch 1** (independent): Tasks 1, 3, 5, 6, 9, 10, 11, 13
|
||||||
|
**Parallel batch 2** (deps resolved): Tasks 2, 4, 12
|
||||||
|
**Parallel batch 3** (deps resolved): Tasks 7, 8
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
# Show Theme Feature Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add a "show theme" input to the header bar that injects theme context into caller background generation and conversation prompts, nudging callers toward the theme without forcing it.
|
||||||
|
|
||||||
|
**Architecture:** Store theme as a string on the Session object. Pass it into `_generate_caller_background_llm()` to bias character creation and into `get_caller_prompt()` so callers are aware of the show's theme during dialog. Frontend adds a text input in the header with a set/clear button, persisted via a new API endpoint.
|
||||||
|
|
||||||
|
**Tech Stack:** Python (FastAPI backend), vanilla HTML/CSS/JS frontend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add theme to Session and API endpoint
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:6192-6218` (Session class)
|
||||||
|
- Modify: `backend/main.py:8731-8759` (settings endpoints area)
|
||||||
|
|
||||||
|
**Step 1: Add `show_theme` field to Session.__init__**
|
||||||
|
|
||||||
|
In `backend/main.py`, inside `Session.__init__` (line ~6217, after `self.intern_monitoring`), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.show_theme: str = "" # Current show theme (e.g. "St. Patrick's Day")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add GET/POST endpoints for show theme**
|
||||||
|
|
||||||
|
Add these endpoints near the existing settings endpoints (~line 8760):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.get("/api/show-theme")
|
||||||
|
async def get_show_theme():
|
||||||
|
return {"theme": session.show_theme}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/show-theme")
|
||||||
|
async def set_show_theme(data: dict):
|
||||||
|
theme = data.get("theme", "").strip()
|
||||||
|
old_theme = session.show_theme
|
||||||
|
session.show_theme = theme
|
||||||
|
if theme:
|
||||||
|
print(f"[Theme] Show theme set: {theme}")
|
||||||
|
elif old_theme:
|
||||||
|
print(f"[Theme] Show theme cleared (was: {old_theme})")
|
||||||
|
return {"theme": session.show_theme}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify the server starts without errors**
|
||||||
|
|
||||||
|
Run: `curl -s http://localhost:8000/api/show-theme | python -m json.tool`
|
||||||
|
Expected: `{"theme": ""}`
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Add show theme to Session and API endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Inject theme into caller background generation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:5208-5330` (`_generate_caller_background_llm`)
|
||||||
|
- Modify: `backend/main.py:5381-5396` (`_pregenerate_backgrounds`)
|
||||||
|
|
||||||
|
**Step 1: Pass theme into background generation prompt**
|
||||||
|
|
||||||
|
In `_generate_caller_background_llm()` (~line 5307), after the line that builds `prompt = f"""Write a brief character description...`, add theme context. Find this section of the prompt string (around line 5316):
|
||||||
|
|
||||||
|
```python
|
||||||
|
{f'CALLER ENERGY: {style_hint}' if style_hint else ''}
|
||||||
|
```
|
||||||
|
|
||||||
|
Immediately after that line (still inside the f-string), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{f"SHOW THEME: Tonight's show theme is '{session.show_theme}'. This caller might have a story or angle related to this theme — or they might not. Not every caller has to be about the theme, but if their reason for calling can naturally connect to it, lean into that connection. The theme should feel like a through-line, not a mandate." if session.show_theme else ''}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify backgrounds generate with theme**
|
||||||
|
|
||||||
|
Set a theme via API, then start a new session and check the server logs for background generation.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8000/api/show-theme -H 'Content-Type: application/json' -d '{"theme": "St. Patricks Day"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Inject show theme into caller background generation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Inject theme into conversation system prompt
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:5995-6094` (`get_caller_prompt`)
|
||||||
|
|
||||||
|
**Step 1: Add theme block to get_caller_prompt**
|
||||||
|
|
||||||
|
In `get_caller_prompt()`, after the `world_context` block is built (around line 6015), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
theme_context = ""
|
||||||
|
if session.show_theme:
|
||||||
|
theme_context = f"\nSHOW THEME: Tonight's show theme is \"{session.show_theme}\". You're aware of the theme — the host mentioned it at the top of the show. If your story or situation connects to it, you might bring it up naturally. But don't force it. Not every caller has to be about the theme. If the host steers you toward the theme, go with it.\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then inject `{theme_context}` into the return f-string. Find this line (~6063):
|
||||||
|
|
||||||
|
```python
|
||||||
|
{relationship_context}{history}{world_context}{emotional_read}
|
||||||
|
```
|
||||||
|
|
||||||
|
Change it to:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{relationship_context}{history}{world_context}{theme_context}{emotional_read}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify prompt includes theme**
|
||||||
|
|
||||||
|
This can be verified by checking server logs during a call (the full prompt is logged at debug level).
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Inject show theme into caller conversation prompt"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Add theme input to frontend header
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/index.html` (header section, lines 11-33)
|
||||||
|
- Modify: `frontend/css/style.css`
|
||||||
|
- Modify: `frontend/js/app.js`
|
||||||
|
|
||||||
|
**Step 1: Add theme input HTML to header**
|
||||||
|
|
||||||
|
In `frontend/index.html`, inside the `<header>` section, after the `.header-buttons` div (line ~19) and before the `#show-clock` div (line ~20), add:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="theme-bar">
|
||||||
|
<label for="show-theme-input" class="theme-label">Theme:</label>
|
||||||
|
<input type="text" id="show-theme-input" class="theme-input" placeholder="e.g. St. Patrick's Day" maxlength="100">
|
||||||
|
<button id="set-theme-btn" class="theme-btn set" title="Set show theme">Set</button>
|
||||||
|
<button id="clear-theme-btn" class="theme-btn clear hidden" title="Clear theme">✕</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add CSS for theme bar**
|
||||||
|
|
||||||
|
In `frontend/css/style.css`, add styles for the theme bar. Place near other header styles:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.theme-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #aaa;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-input {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #f5a623;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-input.active {
|
||||||
|
border-color: #f5a623;
|
||||||
|
background: rgba(245, 166, 35, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn.set {
|
||||||
|
background: #f5a623;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn.set:hover {
|
||||||
|
background: #e6991a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn.clear {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #aaa;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn.clear:hover {
|
||||||
|
background: rgba(255, 80, 80, 0.3);
|
||||||
|
color: #ff5050;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Add JS for theme set/clear**
|
||||||
|
|
||||||
|
In `frontend/js/app.js`, add theme management functions and wire up event listeners. Add near other initialization code:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function loadShowTheme() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/show-theme');
|
||||||
|
const data = await res.json();
|
||||||
|
const input = document.getElementById('show-theme-input');
|
||||||
|
const setBtn = document.getElementById('set-theme-btn');
|
||||||
|
const clearBtn = document.getElementById('clear-theme-btn');
|
||||||
|
if (data.theme) {
|
||||||
|
input.value = data.theme;
|
||||||
|
input.classList.add('active');
|
||||||
|
setBtn.classList.add('hidden');
|
||||||
|
clearBtn.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load show theme:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setShowTheme() {
|
||||||
|
const input = document.getElementById('show-theme-input');
|
||||||
|
const theme = input.value.trim();
|
||||||
|
if (!theme) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/show-theme', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ theme })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.theme) {
|
||||||
|
input.classList.add('active');
|
||||||
|
document.getElementById('set-theme-btn').classList.add('hidden');
|
||||||
|
document.getElementById('clear-theme-btn').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to set show theme:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearShowTheme() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/show-theme', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ theme: '' })
|
||||||
|
});
|
||||||
|
const input = document.getElementById('show-theme-input');
|
||||||
|
input.value = '';
|
||||||
|
input.classList.remove('active');
|
||||||
|
document.getElementById('set-theme-btn').classList.remove('hidden');
|
||||||
|
document.getElementById('clear-theme-btn').classList.add('hidden');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to clear show theme:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire up event listeners (in the DOMContentLoaded or init block):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
document.getElementById('set-theme-btn').addEventListener('click', setShowTheme);
|
||||||
|
document.getElementById('clear-theme-btn').addEventListener('click', clearShowTheme);
|
||||||
|
document.getElementById('show-theme-input').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') setShowTheme();
|
||||||
|
});
|
||||||
|
loadShowTheme();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Test in browser**
|
||||||
|
|
||||||
|
1. Open http://localhost:8000
|
||||||
|
2. Type a theme in the input, click Set — input should highlight amber, Set button hides, X button appears
|
||||||
|
3. Click X — input clears, reverts to normal state
|
||||||
|
4. Refresh page — theme should persist (loaded from API)
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/index.html frontend/css/style.css frontend/js/app.js
|
||||||
|
git commit -m "Add show theme input to header bar"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Clear theme on new session
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py` (session reset logic)
|
||||||
|
|
||||||
|
**Step 1: Find session reset and ensure theme clears**
|
||||||
|
|
||||||
|
Search for where `session = Session()` is called (the new session endpoint). The theme field is already in `__init__` with default `""`, so creating a new Session automatically clears it. No code change needed here — but verify the frontend reloads the theme on new session.
|
||||||
|
|
||||||
|
In the frontend, find the new session button handler and add `loadShowTheme()` after the session reset call completes, so the UI reflects the cleared theme.
|
||||||
|
|
||||||
|
**Step 2: Commit (if any changes needed)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/js/app.js
|
||||||
|
git commit -m "Reload theme state on new session"
|
||||||
|
```
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
# Caller Quality Overhaul — Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Make every caller interesting, layered, and conversationally driven — with specific details to reveal when pressed, strong opinions, and stories that sustain 10+ exchanges without going flat.
|
||||||
|
|
||||||
|
**Architecture:** Four interconnected changes: (1) LLM-generated updates for returning callers, (2) hidden layers in CallerBackground, (3) rebalanced caller prompt from reactive to driven, (4) massively expanded topic pools with more edgy/interesting/specific content. The alien abduction call (Bev, ep45) is the gold standard — professional expertise, progressive reveals, specific details, caller-driven energy.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, OpenRouter LLM API, existing CallerBackground dataclass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Add Hidden Layers to CallerBackground
|
||||||
|
|
||||||
|
The core structural change. Callers currently get ONE situation paragraph. When the host digs in, there's nothing underneath. Add 3 fields to CallerBackground that give the caller ammunition for deeper conversations.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py` — CallerBackground dataclass (~line 6140), LLM background generation prompt (~line 5310)
|
||||||
|
|
||||||
|
**Step 1: Add fields to CallerBackground dataclass**
|
||||||
|
|
||||||
|
Find the CallerBackground dataclass and add three new fields:
|
||||||
|
|
||||||
|
```python
|
||||||
|
hidden_layers: list[str] = field(default_factory=list) # 3 details they haven't mentioned yet — the juicy stuff underneath
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update the LLM background generation prompt**
|
||||||
|
|
||||||
|
In the JSON output spec of the background generation prompt (~line 5322), add:
|
||||||
|
|
||||||
|
```
|
||||||
|
- "hidden_layers": A list of exactly 3 specific details the caller HASN'T mentioned yet but will reveal when pressed. These are the layers underneath the surface story. Think: the part they're embarrassed about, the complication they haven't admitted, the thing that happened AFTER the main event, the detail that changes everything. Each should be 1-2 sentences and SPECIFIC enough to sustain a follow-up question. Example: if the surface story is "my neighbor stole my mail" — layer 1 might be "the stolen mail included a paternity test result," layer 2 might be "the neighbor is actually her ex-husband's new girlfriend," layer 3 might be "she's been retaliating by feeding the neighbor's cat so it likes her better."
|
||||||
|
- "burning_opinion": ONE thing this caller is dying to say — a strong opinion, a controversial take, something they'll volunteer even without being asked. This is what makes them INTERESTING to talk to. Not a generic feeling ("I'm frustrated") but a specific, arguable position ("I think what she did was right and I'd do it again"). Make it provocative enough that the host would want to push back.
|
||||||
|
- "stakes": What's at risk for this caller. Why does this matter? What happens if nothing changes? "My sister won't talk to me" is weak. "If I don't fix this by Thursday, my sister is telling our parents about the money I took and I'll get cut out of the will" is strong. Real consequences, real deadlines, real pressure.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Update the prompt's "WHAT MAKES A GOOD CALLER" section**
|
||||||
|
|
||||||
|
Add to the existing guidance:
|
||||||
|
|
||||||
|
```
|
||||||
|
DEPTH TEST: Before finalizing, ask yourself — if the host asks "tell me more about that" THREE times, does the caller have three genuinely new, interesting things to reveal? If not, the story is too shallow. Add complications, secrets, or consequences that create layers.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Thread hidden_layers into the caller system prompt**
|
||||||
|
|
||||||
|
In `get_caller_prompt()` (~line 6015), add a section that feeds hidden layers to the caller:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Hidden layers — details to reveal when pressed
|
||||||
|
layers_block = ""
|
||||||
|
if caller.get('hidden_layers'):
|
||||||
|
layers = caller['hidden_layers']
|
||||||
|
layers_block = f"""
|
||||||
|
DETAILS YOU HAVEN'T MENTIONED YET (reveal these naturally when Luke asks follow-up questions or digs deeper — don't dump them all at once, let them come out one at a time as the conversation develops):
|
||||||
|
- {layers[0] if len(layers) > 0 else ''}
|
||||||
|
- {layers[1] if len(layers) > 1 else ''}
|
||||||
|
- {layers[2] if len(layers) > 2 else ''}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Burning opinion — something they're dying to say
|
||||||
|
opinion_block = ""
|
||||||
|
if caller.get('burning_opinion'):
|
||||||
|
opinion_block = f"""
|
||||||
|
SOMETHING YOU'RE DYING TO SAY: {caller['burning_opinion']}
|
||||||
|
You'll bring this up when there's a natural opening — you don't need to be asked. This is YOUR call and you have a POINT to make.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Stakes — why this matters
|
||||||
|
stakes_block = ""
|
||||||
|
if caller.get('stakes'):
|
||||||
|
stakes_block = f"""
|
||||||
|
WHAT'S AT STAKE: {caller['stakes']}
|
||||||
|
This isn't abstract — there are real consequences. Mention this when it's relevant. It's why you're calling NOW instead of just thinking about it.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
Insert these blocks into the prompt after `{story_block}`.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: LLM-Generated Updates for Returning Callers
|
||||||
|
|
||||||
|
Currently `_generate_returning_caller_background()` gives returning callers their old summaries and a one-line "something has changed." The dialog model has to improvise an update from nothing. Fix this by using the LLM to generate a specific, interesting new development.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py` — `_generate_returning_caller_background()` (~line 4722)
|
||||||
|
|
||||||
|
**Step 1: Add LLM story continuation to returning caller background**
|
||||||
|
|
||||||
|
After building `prev_section` with call history (~line 4764), add an async LLM call to generate a new development:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Generate a specific new development via LLM
|
||||||
|
update_prompt = f"""A returning caller on a late-night radio show is calling back. Here's their history:
|
||||||
|
|
||||||
|
NAME: {regular['name']}, {age}, {gender}
|
||||||
|
JOB: {job}
|
||||||
|
PREVIOUS CALLS:
|
||||||
|
{chr(10).join(f'- {c["summary"]}' for c in prev_calls[-3:])}
|
||||||
|
|
||||||
|
Generate a SPECIFIC new development in their ongoing situation. Something has changed, escalated, or taken an unexpected turn since their last call. This should be:
|
||||||
|
- Surprising but believable — a natural next chapter, not a soap opera twist
|
||||||
|
- Specific with names, details, and concrete events
|
||||||
|
- Interesting enough to sustain a 5-10 minute conversation
|
||||||
|
- Connected to their previous calls but moving the story FORWARD
|
||||||
|
|
||||||
|
Also generate 3 hidden layers (details they'll reveal when pressed) and a burning opinion (something they're dying to say about how things have developed).
|
||||||
|
|
||||||
|
Respond with JSON:
|
||||||
|
{{
|
||||||
|
"new_development": "2-3 sentences describing what happened since their last call",
|
||||||
|
"hidden_layers": ["detail 1", "detail 2", "detail 3"],
|
||||||
|
"burning_opinion": "their strong take on the situation now"
|
||||||
|
}}
|
||||||
|
|
||||||
|
Output ONLY valid JSON, no markdown fences."""
|
||||||
|
```
|
||||||
|
|
||||||
|
Make this an async function. Call the LLM, parse the JSON, and inject the new_development into `prev_section` and the hidden_layers/burning_opinion into the caller's background data.
|
||||||
|
|
||||||
|
**Step 2: Update the returning caller story_block in get_caller_prompt()**
|
||||||
|
|
||||||
|
Change the returning caller `story_block` (~line 6056) from the passive "something has developed" to:
|
||||||
|
|
||||||
|
```python
|
||||||
|
story_block = f"""YOUR STORY: You're calling back about your ongoing situation. Here's what's NEW since your last call — this is why you're calling tonight:
|
||||||
|
|
||||||
|
{{new_development}}
|
||||||
|
|
||||||
|
You have SPECIFIC things to talk about. Don't just vaguely reference "things have changed" — tell Luke EXACTLY what happened. You're calling because this new development is significant and you need to talk it through. You have details, you have feelings about it, and you have a point you want to make.
|
||||||
|
|
||||||
|
When Luke asks about your previous calls, give him the quick version — then get to what's NEW. The update is why you're here tonight."""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Rebalance Caller Prompt — Driven, Not Passive
|
||||||
|
|
||||||
|
The current prompt over-emphasizes reactivity ("GO WHERE THE HOST TAKES YOU", "Let him drive"). This makes callers passive. Real compelling callers have their own agenda AND respond to the host.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py` — `get_caller_prompt()` (~line 6015)
|
||||||
|
|
||||||
|
**Step 1: Rewrite the "GO WHERE THE HOST TAKES YOU" section**
|
||||||
|
|
||||||
|
Replace the current passive framing (~line 6094) with a balanced version:
|
||||||
|
|
||||||
|
```
|
||||||
|
GO WITH THE HOST BUT BRING YOUR OWN ENERGY. When Luke pushes you in a direction, challenges you, calls you out, or plays devil's advocate — engage with it. Don't shut down, don't deflect. If he says "but isn't that really about your dad?" — sit with that. BUT you're not a passive interview subject. You called because you have something to SAY. Between his questions, volunteer details he didn't ask for. Share the part you're embarrassed about. Drop the detail that changes everything. Push back when you disagree. Ask HIM what he thinks. The best callers are the ones who give the host material AND have their own momentum. You're not here to answer questions — you're here to have a CONVERSATION.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update the "REACT TO LUKE" section**
|
||||||
|
|
||||||
|
Change from pure reactivity (~line 6096) to a balance:
|
||||||
|
|
||||||
|
```
|
||||||
|
REACT TO LUKE — BUT KEEP YOUR MOMENTUM: Your first sentence should respond to what Luke just said. But your SECOND sentence should add something new — a detail he didn't ask for, a complication, a related story, your opinion. Don't just answer and stop. Answer, then GIVE HIM MORE. If he asks "what happened next?" — don't just tell him what happened next. Tell him what happened next AND how it made you feel AND the part you haven't told anyone yet. Fill the space. Dead air is your enemy.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Add a "WHEN LUKE ASKS FOR DETAILS" section**
|
||||||
|
|
||||||
|
Add a new section after the REACT TO LUKE block:
|
||||||
|
|
||||||
|
```
|
||||||
|
WHEN LUKE ASKS FOR DETAILS — DELIVER. If Luke asks "tell me more about that" or "what do you mean?" or pushes for specifics — this is your moment. Don't give a vague one-sentence answer. Paint the picture. Who was there? What did they actually say? What were you doing when it happened? What did the room look like? What was going through your head? Specifics are what make a call memorable. "She was mad" is boring. "She threw her drink at the wall and said 'I knew you'd do this, you're just like your father'" is radio gold. ALWAYS have a specific answer ready — if Luke is digging, it means he's interested. Reward that interest with detail.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Massively Expand Topic Pools
|
||||||
|
|
||||||
|
The current pools have good coverage but need more entries in the categories that produce the best calls: morally complex situations, sex/relationship drama, genuinely weird experiences, and callers with real questions about their lives. The alien abduction call worked because it was SPECIFIC, WEIRD, and the caller had EXPERTISE.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py` — PROBLEMS, STORIES, WEIRD, ADVICE, GOSSIP, TOPIC_CALLIN pools
|
||||||
|
|
||||||
|
**Step 1: Add 80+ new PROBLEMS entries**
|
||||||
|
|
||||||
|
Focus on: moral dilemmas with no clear right answer, sex/relationship situations that are messy and specific, workplace drama with real stakes, family situations with complications. Every entry should pass the "tell me more" test — can you ask 3 follow-up questions and get interesting answers?
|
||||||
|
|
||||||
|
Categories to emphasize:
|
||||||
|
- **Moral dilemmas**: situations where both sides have a point, where doing the "right" thing has real costs
|
||||||
|
- **Sex/relationship mess**: not generic "my partner cheated" but specific, awkward, funny situations with details
|
||||||
|
- **Money/ethics**: found money, inheritance drama, business partner disputes, discovered fraud
|
||||||
|
- **Secrets discovered**: found out something they shouldn't know, now they have to decide what to do with it
|
||||||
|
- **Professional expertise callers**: people calling about weird things they encountered AT WORK (like Bev the nurse with the alien patient) — medical, legal, construction, teaching, law enforcement perspectives
|
||||||
|
|
||||||
|
**Step 2: Add 60+ new WEIRD entries**
|
||||||
|
|
||||||
|
The WEIRD pool produces the best calls when it's specific enough. Focus on:
|
||||||
|
- Genuinely unexplainable personal experiences (not just "something spooky happened")
|
||||||
|
- Bizarre neighbor/coworker behavior with specific ongoing patterns
|
||||||
|
- Objects/places that don't behave normally
|
||||||
|
- Coincidences too specific to be coincidence
|
||||||
|
- Things they've noticed that nobody else seems to see
|
||||||
|
- Callers who have developed elaborate theories about everyday phenomena
|
||||||
|
|
||||||
|
**Step 3: Add 60+ new STORIES entries**
|
||||||
|
|
||||||
|
Focus on stories where the caller was INVOLVED, not just an observer:
|
||||||
|
- Times they did something they can't believe they did
|
||||||
|
- Situations that escalated beyond all reason
|
||||||
|
- Encounters with strangers that changed their perspective
|
||||||
|
- Times they were absolutely, completely wrong about something
|
||||||
|
- Situations where they were the bad guy and know it
|
||||||
|
- Things they got away with that they probably shouldn't have
|
||||||
|
|
||||||
|
**Step 4: Add 40+ new ADVICE entries**
|
||||||
|
|
||||||
|
Real questions people would actually call a radio show about:
|
||||||
|
- "Am I wrong for..." situations with genuine ambiguity
|
||||||
|
- Situations where they've already decided but want validation
|
||||||
|
- Timing/approach questions ("how do I tell my wife...")
|
||||||
|
- Callers who are about to do something and want a gut check
|
||||||
|
- Callers asking about the host's opinion on something specific and controversial
|
||||||
|
|
||||||
|
**Step 5: Add 40+ new GOSSIP entries**
|
||||||
|
|
||||||
|
Gossip works best when the caller has DETAILS and opinions:
|
||||||
|
- Discovered something about someone in their life that changes everything
|
||||||
|
- Workplace gossip with real consequences if it gets out
|
||||||
|
- Small-town drama with escalating stakes
|
||||||
|
- Things overheard that they probably shouldn't have heard
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Increase Response Budget for Substantive Answers
|
||||||
|
|
||||||
|
The current budget gives 15% of standard calls only 450 tokens / 3 sentences. When a caller has a great story, 3 sentences isn't enough. Shift the distribution to give callers more room to breathe, especially early in the call.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py` — `_pick_response_budget()` (~line 8280)
|
||||||
|
|
||||||
|
**Step 1: Adjust default response budget distribution**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Default distribution — give callers room to tell their story
|
||||||
|
roll = random.random()
|
||||||
|
if roll < 0.10:
|
||||||
|
return 500, 4 # 10% — quick response (was 15% / 3 sentences)
|
||||||
|
elif roll < 0.35:
|
||||||
|
return 600, 5 # 25% — normal conversation (was 30% / 4 sentences)
|
||||||
|
elif roll < 0.65:
|
||||||
|
return 700, 6 # 30% — room to breathe (was 30% / 5 sentences)
|
||||||
|
else:
|
||||||
|
return 800, 7 # 35% — telling a story or riffing (was 25% / 6 sentences)
|
||||||
|
```
|
||||||
|
|
||||||
|
Also bump up shape-specific budgets proportionally.
|
||||||
|
|
||||||
|
**Step 2: Increase MIN_RESPONSE_WORDS**
|
||||||
|
|
||||||
|
Change from 20 to 30:
|
||||||
|
```python
|
||||||
|
MIN_RESPONSE_WORDS = 30 # Retry if response is shorter than this
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Quality Review and Integration Test
|
||||||
|
|
||||||
|
Read through all changes, verify they integrate cleanly, and test with a simulated caller generation.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Read: all modified files
|
||||||
|
|
||||||
|
**Step 1: Verify CallerBackground field additions parse correctly**
|
||||||
|
|
||||||
|
Run a test background generation to make sure the new fields (hidden_layers, burning_opinion, stakes) are populated by the LLM and parsed into the dataclass.
|
||||||
|
|
||||||
|
**Step 2: Verify returning caller LLM update generates properly**
|
||||||
|
|
||||||
|
Test with an existing regular (e.g., Shonda) to confirm the LLM generates a specific new development.
|
||||||
|
|
||||||
|
**Step 3: Verify the new prompt sections render correctly in get_caller_prompt()**
|
||||||
|
|
||||||
|
Check that hidden_layers, burning_opinion, and stakes blocks appear in the system prompt for a caller that has them.
|
||||||
|
|
||||||
|
**Step 4: Restart server and verify no import/syntax errors**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pkill -f "uvicorn backend.main:app"
|
||||||
|
/Users/lukemacneil/code/ai-podcast/venv/bin/python -m uvicorn backend.main:app --reload --reload-dir backend --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Cost Dashboard Design
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A dedicated cost analytics dashboard at `/costs` that visualizes LLM and TTS spending across sessions with time-range filtering, model/category breakdowns, and drill-down into individual sessions and calls.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Route:** `/costs` served by FastAPI, standalone page matching the control panel's dark theme
|
||||||
|
- **Database:** SQLite (`data/costs.db`) for cross-session aggregation
|
||||||
|
- **Charts:** Chart.js (vanilla JS, no framework)
|
||||||
|
- **Data migration:** On first run, import existing `data/cost_reports/*.json` into SQLite
|
||||||
|
- **Dual write:** `cost_tracker.py` continues writing JSON reports (backward compat) and also writes to SQLite going forward
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### `sessions`
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| id | TEXT PK | Session ID |
|
||||||
|
| started_at | TIMESTAMP | Session start time |
|
||||||
|
| total_cost | REAL | Total cost USD |
|
||||||
|
| llm_cost | REAL | LLM cost USD |
|
||||||
|
| tts_cost | REAL | TTS cost USD |
|
||||||
|
| total_llm_calls | INTEGER | Number of LLM calls |
|
||||||
|
| total_tts_calls | INTEGER | Number of TTS calls |
|
||||||
|
| total_tokens | INTEGER | Total tokens used |
|
||||||
|
| prompt_tokens | INTEGER | Prompt tokens |
|
||||||
|
| completion_tokens | INTEGER | Completion tokens |
|
||||||
|
|
||||||
|
### `llm_calls`
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| id | INTEGER PK | Auto-increment |
|
||||||
|
| session_id | TEXT FK | References sessions.id |
|
||||||
|
| timestamp | TIMESTAMP | Call time |
|
||||||
|
| category | TEXT | background_gen, caller_dialog, devon_monitor, etc. |
|
||||||
|
| model | TEXT | Model identifier |
|
||||||
|
| prompt_tokens | INTEGER | Prompt tokens |
|
||||||
|
| completion_tokens | INTEGER | Completion tokens |
|
||||||
|
| cost | REAL | Cost USD |
|
||||||
|
| caller_name | TEXT | Caller name (nullable) |
|
||||||
|
| latency_ms | REAL | Response latency |
|
||||||
|
|
||||||
|
### `tts_calls`
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| id | INTEGER PK | Auto-increment |
|
||||||
|
| session_id | TEXT FK | References sessions.id |
|
||||||
|
| timestamp | TIMESTAMP | Call time |
|
||||||
|
| provider | TEXT | Inworld, ElevenLabs, etc. |
|
||||||
|
| voice | TEXT | Voice ID |
|
||||||
|
| char_count | INTEGER | Characters synthesized |
|
||||||
|
| cost | REAL | Cost USD |
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
All new endpoints under `/api/costs/`:
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `GET /api/costs/summary?period=today\|week\|month\|all` | Aggregated totals: spend, LLM/TTS split, call count, tokens, avg cost/session, % change vs previous period |
|
||||||
|
| `GET /api/costs/timeline?period=week\|month\|all&group_by=session\|day` | Time-series for line chart (cost over time) |
|
||||||
|
| `GET /api/costs/models?period=week\|month\|all` | Per-model breakdown for pie/bar charts |
|
||||||
|
| `GET /api/costs/categories?period=week\|month\|all` | Per-category breakdown (background_gen, caller_dialog, etc.) |
|
||||||
|
| `GET /api/costs/sessions?period=week\|month\|all` | Session list with totals, sortable |
|
||||||
|
| `GET /api/costs/session/{id}` | Single session detail: per-caller costs, expensive calls, recommendations |
|
||||||
|
| `GET /api/costs/expensive?period=week\|month\|all&limit=10` | Top N most expensive individual calls |
|
||||||
|
|
||||||
|
Existing `/api/costs` (live session) endpoint remains unchanged.
|
||||||
|
|
||||||
|
## Dashboard Layout
|
||||||
|
|
||||||
|
### Header
|
||||||
|
Time range selector tabs: Today / This Week / This Month / All Time. Clicking any tab refreshes all charts.
|
||||||
|
|
||||||
|
### Row 1 — Summary Cards (4 across)
|
||||||
|
- **Total Spend** with % change vs previous period
|
||||||
|
- **LLM / TTS Split** showing both values
|
||||||
|
- **Total Sessions** in period
|
||||||
|
- **Avg Cost Per Session**
|
||||||
|
|
||||||
|
### Row 2 — Two charts side by side
|
||||||
|
- **Left: Cost Over Time** — line chart, x-axis sessions or days, y-axis dollars. LLM and TTS as separate lines.
|
||||||
|
- **Right: Cost by Model** — doughnut chart with legend showing dollar amounts.
|
||||||
|
|
||||||
|
### Row 3 — Two charts side by side
|
||||||
|
- **Left: Cost by Category** — horizontal bar chart (background_gen, caller_dialog, devon_monitor, etc.)
|
||||||
|
- **Right: Cost Per Session Trend** — bar chart, each bar a session, colored above/below average.
|
||||||
|
|
||||||
|
### Row 4 — Tables
|
||||||
|
- **Most Expensive Calls** — top 10 LLM calls (model, category, caller, tokens, cost, timestamp)
|
||||||
|
- **Session List** — all sessions, sortable by date/cost, clickable for detail view.
|
||||||
|
|
||||||
|
### Session Detail View (click-through)
|
||||||
|
- Per-caller cost breakdown
|
||||||
|
- Call-by-call timeline
|
||||||
|
- Recommendations from existing `_generate_recommendations()` logic
|
||||||
|
|
||||||
|
## Visual Style
|
||||||
|
Matches the control panel's existing dark theme. Same fonts, colors, card styles.
|
||||||
|
|
||||||
|
## What's NOT in v1
|
||||||
|
- SignalWire cost tracking (future addition)
|
||||||
|
- Real-time WebSocket updates (polling on page load is sufficient)
|
||||||
|
- Cost alerts/budgets
|
||||||
|
- Export to CSV
|
||||||
@@ -0,0 +1,810 @@
|
|||||||
|
# Cost Dashboard Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Build a cost analytics dashboard at `/costs` that visualizes LLM and TTS spending across sessions with time-range filtering, charts, and drill-down.
|
||||||
|
|
||||||
|
**Architecture:** SQLite database (`data/costs.db`) stores all cost records. Existing JSON reports are imported on first run. `cost_tracker.py` dual-writes to both JSON and SQLite. New API endpoints serve aggregated data. Standalone HTML page with Chart.js renders the dashboard.
|
||||||
|
|
||||||
|
**Tech Stack:** Python/FastAPI, SQLite, Chart.js (CDN), vanilla JS, CSS custom properties matching existing dark theme.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: SQLite Database Module
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `backend/services/cost_db.py`
|
||||||
|
|
||||||
|
**Step 1: Create the database module with schema**
|
||||||
|
|
||||||
|
Create `backend/services/cost_db.py` with:
|
||||||
|
- `init_db(db_path)` — creates tables if not exist, returns connection
|
||||||
|
- `import_json_reports(db_path, reports_dir)` — scans `data/cost_reports/*.json`, imports any sessions not already in the DB
|
||||||
|
- `get_db()` — returns a connection to `data/costs.db`, calls `init_db` on first use
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_PATH = Path(__file__).parent.parent.parent / "data" / "costs.db"
|
||||||
|
REPORTS_DIR = Path(__file__).parent.parent.parent / "data" / "cost_reports"
|
||||||
|
|
||||||
|
_connection = None
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
started_at TIMESTAMP,
|
||||||
|
total_cost REAL DEFAULT 0,
|
||||||
|
llm_cost REAL DEFAULT 0,
|
||||||
|
tts_cost REAL DEFAULT 0,
|
||||||
|
total_llm_calls INTEGER DEFAULT 0,
|
||||||
|
total_tts_calls INTEGER DEFAULT 0,
|
||||||
|
total_tokens INTEGER DEFAULT 0,
|
||||||
|
prompt_tokens INTEGER DEFAULT 0,
|
||||||
|
completion_tokens INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS llm_calls (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
timestamp TIMESTAMP,
|
||||||
|
category TEXT,
|
||||||
|
model TEXT,
|
||||||
|
prompt_tokens INTEGER DEFAULT 0,
|
||||||
|
completion_tokens INTEGER DEFAULT 0,
|
||||||
|
cost REAL DEFAULT 0,
|
||||||
|
caller_name TEXT,
|
||||||
|
latency_ms REAL DEFAULT 0,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tts_calls (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
timestamp TIMESTAMP,
|
||||||
|
provider TEXT,
|
||||||
|
voice TEXT,
|
||||||
|
char_count INTEGER DEFAULT 0,
|
||||||
|
cost REAL DEFAULT 0,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_session ON llm_calls(session_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_timestamp ON llm_calls(timestamp);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_category ON llm_calls(category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_model ON llm_calls(model);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tts_session ON tts_calls(session_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tts_timestamp ON tts_calls(timestamp);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
global _connection
|
||||||
|
if _connection is None:
|
||||||
|
_connection = sqlite3.connect(str(DB_PATH), check_same_thread=False)
|
||||||
|
_connection.row_factory = sqlite3.Row
|
||||||
|
_connection.executescript(SCHEMA)
|
||||||
|
import_json_reports()
|
||||||
|
return _connection
|
||||||
|
|
||||||
|
|
||||||
|
def import_json_reports():
|
||||||
|
db = _connection
|
||||||
|
if not REPORTS_DIR.exists():
|
||||||
|
return
|
||||||
|
existing = {row[0] for row in db.execute("SELECT id FROM sessions").fetchall()}
|
||||||
|
for f in sorted(REPORTS_DIR.glob("*.json")):
|
||||||
|
try:
|
||||||
|
data = json.loads(f.read_text())
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
continue
|
||||||
|
session_id = data.get("session_id", f.stem)
|
||||||
|
if session_id in existing:
|
||||||
|
continue
|
||||||
|
saved_at = data.get("saved_at")
|
||||||
|
started_at = datetime.fromtimestamp(saved_at).isoformat() if saved_at else None
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO sessions (id, started_at, total_cost, llm_cost, tts_cost, total_llm_calls, total_tts_calls, total_tokens, prompt_tokens, completion_tokens) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(session_id, started_at, data.get("total_cost_usd", 0), data.get("llm_cost_usd", 0), data.get("tts_cost_usd", 0), data.get("total_llm_calls", 0), len(data.get("raw_tts_records", [])), data.get("total_tokens", 0), data.get("prompt_tokens", 0), data.get("completion_tokens", 0)),
|
||||||
|
)
|
||||||
|
for rec in data.get("raw_llm_records", []):
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO llm_calls (session_id, timestamp, category, model, prompt_tokens, completion_tokens, cost, caller_name, latency_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(session_id, rec.get("timestamp"), rec.get("category"), rec.get("model"), rec.get("prompt_tokens", 0), rec.get("completion_tokens", 0), rec.get("cost_usd", 0), rec.get("caller_name"), rec.get("latency_ms", 0)),
|
||||||
|
)
|
||||||
|
for rec in data.get("raw_tts_records", []):
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO tts_calls (session_id, timestamp, provider, voice, char_count, cost) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(session_id, rec.get("timestamp"), rec.get("provider"), rec.get("voice"), rec.get("char_count", 0), rec.get("cost_usd", 0)),
|
||||||
|
)
|
||||||
|
existing.add(session_id)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def record_llm_call(session_id, timestamp, category, model, prompt_tokens, completion_tokens, cost, caller_name, latency_ms):
|
||||||
|
db = get_db()
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO llm_calls (session_id, timestamp, category, model, prompt_tokens, completion_tokens, cost, caller_name, latency_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(session_id, timestamp, category, model, prompt_tokens, completion_tokens, cost, caller_name, latency_ms),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def record_tts_call(session_id, timestamp, provider, voice, char_count, cost):
|
||||||
|
db = get_db()
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO tts_calls (session_id, timestamp, provider, voice, char_count, cost) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(session_id, timestamp, provider, voice, char_count, cost),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_session(session_id, started_at=None):
|
||||||
|
db = get_db()
|
||||||
|
existing = db.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
||||||
|
if not existing:
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO sessions (id, started_at) VALUES (?, ?)",
|
||||||
|
(session_id, started_at or datetime.now().isoformat()),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def update_session_totals(session_id):
|
||||||
|
db = get_db()
|
||||||
|
llm = db.execute(
|
||||||
|
"SELECT COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(prompt_tokens), 0) as pt, COALESCE(SUM(completion_tokens), 0) as ct FROM llm_calls WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
tts = db.execute(
|
||||||
|
"SELECT COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost FROM tts_calls WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
total_tokens = llm["pt"] + llm["ct"]
|
||||||
|
total_cost = llm["cost"] + tts["cost"]
|
||||||
|
db.execute(
|
||||||
|
"UPDATE sessions SET total_cost=?, llm_cost=?, tts_cost=?, total_llm_calls=?, total_tts_calls=?, total_tokens=?, prompt_tokens=?, completion_tokens=? WHERE id=?",
|
||||||
|
(total_cost, llm["cost"], tts["cost"], llm["calls"], tts["calls"], total_tokens, llm["pt"], llm["ct"], session_id),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _period_filter(period):
|
||||||
|
now = datetime.now()
|
||||||
|
if period == "today":
|
||||||
|
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
elif period == "week":
|
||||||
|
start = now - timedelta(days=now.weekday())
|
||||||
|
start = start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
elif period == "month":
|
||||||
|
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return start.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def get_summary(period="all"):
|
||||||
|
db = get_db()
|
||||||
|
start = _period_filter(period)
|
||||||
|
if start:
|
||||||
|
where = "WHERE started_at >= ?"
|
||||||
|
params = (start,)
|
||||||
|
prev_start = _get_previous_period_start(period)
|
||||||
|
prev_where = "WHERE started_at >= ? AND started_at < ?"
|
||||||
|
prev_params = (prev_start, start)
|
||||||
|
else:
|
||||||
|
where = ""
|
||||||
|
params = ()
|
||||||
|
prev_where = None
|
||||||
|
prev_params = ()
|
||||||
|
|
||||||
|
row = db.execute(
|
||||||
|
f"SELECT COUNT(*) as sessions, COALESCE(SUM(total_cost), 0) as total_cost, COALESCE(SUM(llm_cost), 0) as llm_cost, COALESCE(SUM(tts_cost), 0) as tts_cost, COALESCE(SUM(total_llm_calls), 0) as total_calls, COALESCE(SUM(total_tokens), 0) as total_tokens FROM sessions {where}",
|
||||||
|
params,
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"total_cost": round(row["total_cost"], 4),
|
||||||
|
"llm_cost": round(row["llm_cost"], 4),
|
||||||
|
"tts_cost": round(row["tts_cost"], 4),
|
||||||
|
"sessions": row["sessions"],
|
||||||
|
"total_calls": row["total_calls"],
|
||||||
|
"total_tokens": row["total_tokens"],
|
||||||
|
"avg_cost_per_session": round(row["total_cost"] / max(row["sessions"], 1), 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
if prev_where:
|
||||||
|
prev = db.execute(
|
||||||
|
f"SELECT COALESCE(SUM(total_cost), 0) as total_cost FROM sessions {prev_where}",
|
||||||
|
prev_params,
|
||||||
|
).fetchone()
|
||||||
|
prev_cost = prev["total_cost"]
|
||||||
|
if prev_cost > 0:
|
||||||
|
result["pct_change"] = round((row["total_cost"] - prev_cost) / prev_cost * 100, 1)
|
||||||
|
else:
|
||||||
|
result["pct_change"] = None
|
||||||
|
else:
|
||||||
|
result["pct_change"] = None
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _get_previous_period_start(period):
|
||||||
|
now = datetime.now()
|
||||||
|
if period == "today":
|
||||||
|
return (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
||||||
|
elif period == "week":
|
||||||
|
start_of_week = now - timedelta(days=now.weekday())
|
||||||
|
return (start_of_week - timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
||||||
|
elif period == "month":
|
||||||
|
first_of_month = now.replace(day=1)
|
||||||
|
prev_month = first_of_month - timedelta(days=1)
|
||||||
|
return prev_month.replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_timeline(period="all", group_by="session"):
|
||||||
|
db = get_db()
|
||||||
|
start = _period_filter(period)
|
||||||
|
if group_by == "day":
|
||||||
|
if start:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT DATE(started_at) as date, SUM(llm_cost) as llm_cost, SUM(tts_cost) as tts_cost, SUM(total_cost) as total_cost, COUNT(*) as sessions FROM sessions WHERE started_at >= ? GROUP BY DATE(started_at) ORDER BY date",
|
||||||
|
(start,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT DATE(started_at) as date, SUM(llm_cost) as llm_cost, SUM(tts_cost) as tts_cost, SUM(total_cost) as total_cost, COUNT(*) as sessions FROM sessions GROUP BY DATE(started_at) ORDER BY date"
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
if start:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT id, started_at, llm_cost, tts_cost, total_cost FROM sessions WHERE started_at >= ? ORDER BY started_at",
|
||||||
|
(start,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT id, started_at, llm_cost, tts_cost, total_cost FROM sessions ORDER BY started_at"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_models(period="all"):
|
||||||
|
db = get_db()
|
||||||
|
start = _period_filter(period)
|
||||||
|
if start:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT l.model, COUNT(*) as calls, COALESCE(SUM(l.cost), 0) as cost, COALESCE(SUM(l.prompt_tokens), 0) as prompt_tokens, COALESCE(SUM(l.completion_tokens), 0) as completion_tokens FROM llm_calls l JOIN sessions s ON l.session_id = s.id WHERE s.started_at >= ? GROUP BY l.model ORDER BY cost DESC",
|
||||||
|
(start,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT model, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(prompt_tokens), 0) as prompt_tokens, COALESCE(SUM(completion_tokens), 0) as completion_tokens FROM llm_calls GROUP BY model ORDER BY cost DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_categories(period="all"):
|
||||||
|
db = get_db()
|
||||||
|
start = _period_filter(period)
|
||||||
|
if start:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT l.category, COUNT(*) as calls, COALESCE(SUM(l.cost), 0) as cost, COALESCE(SUM(l.prompt_tokens + l.completion_tokens), 0) as tokens FROM llm_calls l JOIN sessions s ON l.session_id = s.id WHERE s.started_at >= ? GROUP BY l.category ORDER BY cost DESC",
|
||||||
|
(start,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT category, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(prompt_tokens + completion_tokens), 0) as tokens FROM llm_calls GROUP BY category ORDER BY cost DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_sessions_list(period="all"):
|
||||||
|
db = get_db()
|
||||||
|
start = _period_filter(period)
|
||||||
|
if start:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT id, started_at, total_cost, llm_cost, tts_cost, total_llm_calls, total_tokens FROM sessions WHERE started_at >= ? ORDER BY started_at DESC",
|
||||||
|
(start,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT id, started_at, total_cost, llm_cost, tts_cost, total_llm_calls, total_tokens FROM sessions ORDER BY started_at DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_detail(session_id):
|
||||||
|
db = get_db()
|
||||||
|
session = db.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
||||||
|
if not session:
|
||||||
|
return None
|
||||||
|
by_caller = db.execute(
|
||||||
|
"SELECT caller_name, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(prompt_tokens + completion_tokens), 0) as tokens FROM llm_calls WHERE session_id = ? AND caller_name != '' GROUP BY caller_name ORDER BY cost DESC",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
by_model = db.execute(
|
||||||
|
"SELECT model, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost FROM llm_calls WHERE session_id = ? GROUP BY model ORDER BY cost DESC",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
by_category = db.execute(
|
||||||
|
"SELECT category, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost FROM llm_calls WHERE session_id = ? GROUP BY category ORDER BY cost DESC",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
expensive = db.execute(
|
||||||
|
"SELECT category, model, caller_name, cost, prompt_tokens, completion_tokens, latency_ms, timestamp FROM llm_calls WHERE session_id = ? ORDER BY cost DESC LIMIT 10",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
tts = db.execute(
|
||||||
|
"SELECT provider, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(char_count), 0) as chars FROM tts_calls WHERE session_id = ? GROUP BY provider ORDER BY cost DESC",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
return {
|
||||||
|
"session": dict(session),
|
||||||
|
"by_caller": [dict(r) for r in by_caller],
|
||||||
|
"by_model": [dict(r) for r in by_model],
|
||||||
|
"by_category": [dict(r) for r in by_category],
|
||||||
|
"expensive_calls": [dict(r) for r in expensive],
|
||||||
|
"tts_by_provider": [dict(r) for r in tts],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_expensive_calls(period="all", limit=10):
|
||||||
|
db = get_db()
|
||||||
|
start = _period_filter(period)
|
||||||
|
if start:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT l.category, l.model, l.caller_name, l.cost, l.prompt_tokens, l.completion_tokens, l.latency_ms, l.timestamp, l.session_id FROM llm_calls l JOIN sessions s ON l.session_id = s.id WHERE s.started_at >= ? ORDER BY l.cost DESC LIMIT ?",
|
||||||
|
(start, limit),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT category, model, caller_name, cost, prompt_tokens, completion_tokens, latency_ms, timestamp, session_id FROM llm_calls ORDER BY cost DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify the module loads**
|
||||||
|
|
||||||
|
Run: `cd /Users/lukemacneil/code/ai-podcast && python -c "from backend.services.cost_db import get_db; db = get_db(); print('OK, sessions:', db.execute('SELECT COUNT(*) FROM sessions').fetchone()[0])"`
|
||||||
|
|
||||||
|
Expected: `OK, sessions: 18` (or however many JSON reports exist)
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/services/cost_db.py
|
||||||
|
git commit -m "Add SQLite cost database module with JSON import"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Integrate SQLite Writes into Cost Tracker
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/services/cost_tracker.py`
|
||||||
|
|
||||||
|
**Step 1: Add dual-write to `record_llm_call`**
|
||||||
|
|
||||||
|
At the top of `cost_tracker.py`, add the import:
|
||||||
|
```python
|
||||||
|
from backend.services import cost_db
|
||||||
|
```
|
||||||
|
|
||||||
|
In `record_llm_call()` (around line 148, after appending to `self.llm_records`), add:
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
cost_db.ensure_session(self._session_id)
|
||||||
|
cost_db.record_llm_call(
|
||||||
|
self._session_id, record.timestamp, record.category, record.model,
|
||||||
|
record.prompt_tokens, record.completion_tokens, record.cost_usd,
|
||||||
|
record.caller_name, record.latency_ms,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # don't break show over analytics
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add dual-write to `record_tts_call`**
|
||||||
|
|
||||||
|
In `record_tts_call()` (around line 166, after appending to `self.tts_records`), add:
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
cost_db.record_tts_call(
|
||||||
|
self._session_id, record.timestamp, record.provider, record.voice,
|
||||||
|
record.char_count, record.cost_usd,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Add session_id tracking to `__init__`**
|
||||||
|
|
||||||
|
Add `self._session_id` to the constructor. Generate it from timestamp:
|
||||||
|
```python
|
||||||
|
self._session_id = f"session-{datetime.now().strftime('%Y-%m-%d_%H%M%S')}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Update session totals on `save()`**
|
||||||
|
|
||||||
|
In the `save()` method, after writing the JSON file, add:
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
cost_db.update_session_totals(self._session_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/services/cost_tracker.py
|
||||||
|
git commit -m "Dual-write cost records to SQLite"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: API Endpoints
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py` (add routes near existing `/api/costs` endpoints around line 10299)
|
||||||
|
|
||||||
|
**Step 1: Add new cost dashboard endpoints**
|
||||||
|
|
||||||
|
Add these routes near the existing cost endpoints (around line 10308):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from backend.services import cost_db
|
||||||
|
|
||||||
|
@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:
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
return JSONResponse(status_code=404, content={"error": "Session not found"})
|
||||||
|
return detail
|
||||||
|
|
||||||
|
@app.get("/api/costs/expensive")
|
||||||
|
async def get_expensive_calls(period: str = "all", limit: int = 10):
|
||||||
|
return cost_db.get_expensive_calls(period, limit)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add route to serve the costs page**
|
||||||
|
|
||||||
|
Near the existing root route (around line 7654), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.get("/costs")
|
||||||
|
async def costs_page():
|
||||||
|
return FileResponse(frontend_dir / "costs.html")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify endpoints respond**
|
||||||
|
|
||||||
|
Run the server: `python -m uvicorn backend.main:app --reload --reload-dir backend --host 0.0.0.0 --port 8000`
|
||||||
|
|
||||||
|
Test: `curl -s http://localhost:8000/api/costs/summary?period=all | python -m json.tool`
|
||||||
|
|
||||||
|
Expected: JSON with total_cost, llm_cost, tts_cost, sessions, etc.
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Add cost dashboard API endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Dashboard HTML
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/costs.html`
|
||||||
|
|
||||||
|
**Step 1: Create the dashboard page**
|
||||||
|
|
||||||
|
Create `frontend/costs.html` — standalone HTML page with:
|
||||||
|
- Chart.js from CDN (`https://cdn.jsdelivr.net/npm/chart.js`)
|
||||||
|
- Link to `css/style.css` (shared theme) and `css/costs.css` (dashboard-specific)
|
||||||
|
- Script tag for `js/costs.js`
|
||||||
|
- Structure: header with time range tabs, 4 summary cards, 4 chart containers, 2 tables
|
||||||
|
- Use the same CSS variables as the control panel (`--bg`, `--bg-light`, `--accent`, `--text`, etc.)
|
||||||
|
|
||||||
|
Layout structure:
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Cost Dashboard - Luke at the Roost</title>
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/css/costs.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="costs-header">
|
||||||
|
<h1>Cost Dashboard</h1>
|
||||||
|
<a href="/" class="back-link">Back to Show</a>
|
||||||
|
<nav class="period-tabs">
|
||||||
|
<button class="period-tab active" data-period="all">All Time</button>
|
||||||
|
<button class="period-tab" data-period="month">This Month</button>
|
||||||
|
<button class="period-tab" data-period="week">This Week</button>
|
||||||
|
<button class="period-tab" data-period="today">Today</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main class="costs-main">
|
||||||
|
<section class="summary-cards">
|
||||||
|
<div class="card" id="card-total">
|
||||||
|
<div class="card-label">Total Spend</div>
|
||||||
|
<div class="card-value" id="total-spend">--</div>
|
||||||
|
<div class="card-change" id="total-change"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="card-llm-tts">
|
||||||
|
<div class="card-label">LLM / TTS</div>
|
||||||
|
<div class="card-value" id="llm-tts-split">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="card-sessions">
|
||||||
|
<div class="card-label">Sessions</div>
|
||||||
|
<div class="card-value" id="session-count">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="card-avg">
|
||||||
|
<div class="card-label">Avg / Session</div>
|
||||||
|
<div class="card-value" id="avg-cost">--</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="chart-row">
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3>Cost Over Time</h3>
|
||||||
|
<canvas id="timeline-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3>Cost by Model</h3>
|
||||||
|
<canvas id="model-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="chart-row">
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3>Cost by Category</h3>
|
||||||
|
<canvas id="category-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3>Cost Per Session</h3>
|
||||||
|
<canvas id="session-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="tables-section">
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>Most Expensive Calls</h3>
|
||||||
|
<table id="expensive-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Model</th><th>Category</th><th>Caller</th><th>Tokens</th><th>Cost</th><th>Latency</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>Sessions</h3>
|
||||||
|
<table id="sessions-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th>LLM</th><th>TTS</th><th>Total</th><th>Calls</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="session-detail hidden" id="session-detail">
|
||||||
|
<h2>Session Detail: <span id="detail-session-id"></span></h2>
|
||||||
|
<button class="close-detail" id="close-detail">Back</button>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>By Caller</h3>
|
||||||
|
<table id="detail-caller-table">
|
||||||
|
<thead><tr><th>Caller</th><th>Calls</th><th>Cost</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>By Category</h3>
|
||||||
|
<table id="detail-category-table">
|
||||||
|
<thead><tr><th>Category</th><th>Calls</th><th>Cost</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>By Model</h3>
|
||||||
|
<table id="detail-model-table">
|
||||||
|
<thead><tr><th>Model</th><th>Calls</th><th>Cost</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>Most Expensive Calls</h3>
|
||||||
|
<table id="detail-expensive-table">
|
||||||
|
<thead><tr><th>Category</th><th>Model</th><th>Caller</th><th>Cost</th><th>Tokens</th><th>Latency</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="/js/costs.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/costs.html
|
||||||
|
git commit -m "Add cost dashboard HTML page"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Dashboard CSS
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/css/costs.css`
|
||||||
|
|
||||||
|
**Step 1: Create dashboard-specific styles**
|
||||||
|
|
||||||
|
Create `frontend/css/costs.css` using the existing CSS variables from `style.css`. Key styles:
|
||||||
|
- `.costs-header` — flex row with title, back link, and period tabs
|
||||||
|
- `.period-tabs` / `.period-tab` — tab buttons, active state uses `--accent`
|
||||||
|
- `.summary-cards` — 4-column grid
|
||||||
|
- `.card` — `background: var(--bg-light)`, border, rounded corners matching `--radius`
|
||||||
|
- `.card-value` — large font, `color: var(--text)`
|
||||||
|
- `.card-change` — small text, green for negative (saving), red for positive (increase)
|
||||||
|
- `.chart-row` — 2-column grid
|
||||||
|
- `.chart-container` — padded card with canvas
|
||||||
|
- `.table-container` — styled tables matching dark theme
|
||||||
|
- `.session-detail` — full-width detail view
|
||||||
|
- Responsive: single column below 768px
|
||||||
|
|
||||||
|
Use `var(--bg)`, `var(--bg-light)`, `var(--accent)`, `var(--text)`, `var(--text-muted)`, `var(--radius)`, `var(--radius-sm)`, `var(--transition)` throughout.
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/css/costs.css
|
||||||
|
git commit -m "Add cost dashboard CSS"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Dashboard JavaScript
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/js/costs.js`
|
||||||
|
|
||||||
|
**Step 1: Create the dashboard JS**
|
||||||
|
|
||||||
|
Create `frontend/js/costs.js` with:
|
||||||
|
|
||||||
|
**State:**
|
||||||
|
```javascript
|
||||||
|
let currentPeriod = 'all';
|
||||||
|
let charts = {}; // store Chart.js instances for destroy/recreate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Init:**
|
||||||
|
```javascript
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('.period-tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
document.querySelector('.period-tab.active').classList.remove('active');
|
||||||
|
tab.classList.add('active');
|
||||||
|
currentPeriod = tab.dataset.period;
|
||||||
|
loadDashboard();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('close-detail').addEventListener('click', closeDetail);
|
||||||
|
loadDashboard();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data loading — `loadDashboard()`:**
|
||||||
|
- Fetch all endpoints in parallel: summary, timeline, models, categories, sessions, expensive
|
||||||
|
- Call render functions for each section
|
||||||
|
|
||||||
|
**Render functions:**
|
||||||
|
- `renderSummary(data)` — populate the 4 summary cards, format as `$X.XX`, show % change with color
|
||||||
|
- `renderTimeline(data)` — Chart.js line chart with LLM and TTS as separate datasets, `--accent` and `--devon` colors
|
||||||
|
- `renderModels(data)` — Chart.js doughnut chart with model name labels
|
||||||
|
- `renderCategories(data)` — Chart.js horizontal bar chart
|
||||||
|
- `renderSessionBars(data)` — Chart.js bar chart, bars colored based on above/below average
|
||||||
|
- `renderExpensiveTable(data)` — populate table rows
|
||||||
|
- `renderSessionsTable(data)` — populate table rows with click handler to show detail
|
||||||
|
- `showSessionDetail(sessionId)` — fetch `/api/costs/session/{id}`, show detail section, populate tables
|
||||||
|
- `closeDetail()` — hide detail section
|
||||||
|
|
||||||
|
**Chart.js config notes:**
|
||||||
|
- Use dark theme: grid lines `rgba(245, 240, 229, 0.1)` (--text at 10%), tick color `var(--text-muted)`
|
||||||
|
- Chart colors palette: `#e8791d` (accent), `#c4944a` (devon), `#5a8a3c` (green), `#cc2222` (red), `#4a8ac4` (blue), `#8a5ac4` (purple), `#c4845a` (tan)
|
||||||
|
- Tooltips: dark background, light text
|
||||||
|
- Destroy existing chart instance before creating new one (prevents memory leaks on period switch)
|
||||||
|
|
||||||
|
**Utility:**
|
||||||
|
- `formatCost(n)` — returns `$X.XX` or `$X.XXXX` for small amounts
|
||||||
|
- `formatDate(iso)` — returns readable date
|
||||||
|
- `shortenModel(name)` — strip provider prefix from model names for chart labels
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/js/costs.js
|
||||||
|
git commit -m "Add cost dashboard JavaScript with Chart.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Integration Test
|
||||||
|
|
||||||
|
**Step 1: Manual verification checklist**
|
||||||
|
|
||||||
|
Start the server and navigate to `http://localhost:8000/costs`:
|
||||||
|
|
||||||
|
1. Page loads with dark theme, no console errors
|
||||||
|
2. All Time tab is active by default, shows all 18 sessions
|
||||||
|
3. Summary cards show total spend, LLM/TTS split, session count, avg cost
|
||||||
|
4. Cost Over Time line chart renders with data points
|
||||||
|
5. Cost by Model doughnut shows model breakdown
|
||||||
|
6. Cost by Category bar chart shows category breakdown
|
||||||
|
7. Cost Per Session bars render with above/below-average coloring
|
||||||
|
8. Most Expensive Calls table populated
|
||||||
|
9. Sessions table populated, rows clickable
|
||||||
|
10. Click a session row → detail view shows with per-caller, per-model, per-category tables
|
||||||
|
11. Click "Back" → returns to main dashboard
|
||||||
|
12. Switch to "This Week" tab → all charts update (may show fewer/no data)
|
||||||
|
13. Switch to "This Month" → charts update with March data
|
||||||
|
14. Switch back to "All Time" → full data restored
|
||||||
|
|
||||||
|
**Step 2: Commit all remaining changes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "Cost dashboard complete — SQLite backend, Chart.js frontend"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Summary
|
||||||
|
|
||||||
|
| Action | File |
|
||||||
|
|--------|------|
|
||||||
|
| Create | `backend/services/cost_db.py` |
|
||||||
|
| Modify | `backend/services/cost_tracker.py` |
|
||||||
|
| Modify | `backend/main.py` |
|
||||||
|
| Create | `frontend/costs.html` |
|
||||||
|
| Create | `frontend/css/costs.css` |
|
||||||
|
| Create | `frontend/js/costs.js` |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Chart.js loaded from CDN (no npm install needed)
|
||||||
|
- SQLite is stdlib (no pip install needed)
|
||||||
|
- No new Python packages required
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Relocate the Show to Alpine, TX / Big Bend
|
||||||
|
|
||||||
|
**Date:** 2026-05-31
|
||||||
|
**Status:** Approved, implementing
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Luke moved to Alpine, Texas. Move the show's world from southern New Mexico
|
||||||
|
(Deming/Lordsburg) to the Big Bend region of far West Texas. Callers should come
|
||||||
|
from and be familiar with Alpine, Marfa, Marathon, Terlingua, Fort Stockton, and
|
||||||
|
the Big Bend area. Devon moves with the show. Silas gets an arc beat that
|
||||||
|
relocates The Wellspring to Terlingua.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### 1. Caller generation — `backend/services/caller_gen.py`
|
||||||
|
|
||||||
|
- `BATCH_SYSTEM_PROMPT` opener: "…radio show in New Mexico" → "…in Alpine, Texas,
|
||||||
|
in the Big Bend country of far West Texas."
|
||||||
|
- Add a **geographic knowledge block** (real, accurate facts) so callers ground
|
||||||
|
themselves in real places instead of generic desert. Towns:
|
||||||
|
- **Alpine** — hub (~6k), Brewster County seat, Sul Ross State University,
|
||||||
|
mile-high ranching/railroad town, small arts scene.
|
||||||
|
- **Marfa** — minimalist-art tourist town (Chinati/Donald Judd, Prada Marfa),
|
||||||
|
the Marfa Lights, old ranching families vs. hipster influx.
|
||||||
|
- **Marathon** — tiny, the Gage Hotel, east gateway to Big Bend NP.
|
||||||
|
- **Terlingua** — quicksilver-mining ghost town, famous chili cookoff,
|
||||||
|
river-rafting outfitters, off-grid desert eccentrics, the Starlight Theatre,
|
||||||
|
near the Rio Grande / Mexico border.
|
||||||
|
- **Fort Stockton** — oilfield/I-10 town to the north, Pecos County, Paisano
|
||||||
|
Pete, working-class.
|
||||||
|
- **Big Bend** — Chisos Mountains, Rio Grande, dark skies (McDonald Observatory
|
||||||
|
near Fort Davis), remote, border proximity, Permian Basin oil money north.
|
||||||
|
- Rule: only reference real places/facts; don't invent businesses or landmarks.
|
||||||
|
|
||||||
|
### 2. Whisper prompt — `backend/services/transcription.py`
|
||||||
|
|
||||||
|
Update locale and seed proper nouns: "…a late-night radio talk show in Alpine,
|
||||||
|
Texas, in the Big Bend region. Callers reference Alpine, Marfa, Marathon,
|
||||||
|
Terlingua, Fort Stockton, and Big Bend."
|
||||||
|
|
||||||
|
### 3. Devon — `backend/services/intern.py`
|
||||||
|
|
||||||
|
Moved with the show. Keep "Communications degree from NMSU" (alma mater);
|
||||||
|
"You live in a studio in Deming" → "studio in Alpine."
|
||||||
|
|
||||||
|
### 4. Silas — relocate The Wellspring to Terlingua
|
||||||
|
|
||||||
|
- `~/code/dotfiles/silas/silas.md`: identity location Deming → Terlingua badlands;
|
||||||
|
update `arc_state` frontmatter to the relocation; add an Arc Log entry
|
||||||
|
(2026-05-31) — post-reckoning fresh start, moving the commune to desert outside
|
||||||
|
Terlingua, the move straining the forty members.
|
||||||
|
- `data/regulars.json`: update Silas's `job`, `location`, and the
|
||||||
|
`stable_seeds.style` "commune outside Deming" → Terlingua. Leave the 6 past
|
||||||
|
`call_history` summaries untouched — they happened in Deming; the move is the
|
||||||
|
new development.
|
||||||
|
|
||||||
|
### 5. Weather/town-news enrichment — `backend/main.py` (now a real feature)
|
||||||
|
|
||||||
|
The block in `enrich_caller_background()` called `_get_town_from_location` /
|
||||||
|
`_get_weather_for_town`, which never existed — silently NameError-ing. Implement:
|
||||||
|
|
||||||
|
- `BIG_BEND_TOWNS`: dict of town → (lat, lon) for Alpine, Marfa, Marathon,
|
||||||
|
Terlingua, Fort Stockton, Big Bend (Chisos Basin), Fort Davis, Presidio.
|
||||||
|
- `_get_town_from_location(text)`: scan lowercased text for a known town,
|
||||||
|
return canonical key (handles "ft stockton"/"fort stockton").
|
||||||
|
- `_get_weather_for_town(town)`: Open-Meteo current weather (free, no key),
|
||||||
|
WMO weather_code → phrase, returns e.g. "58°F, clear skies".
|
||||||
|
- Update town-news query: NM/AZ branch → `f"{town.title()} Texas"`.
|
||||||
|
- Wire session base_ctx `weather` (was hardcoded "cool desert night") to live
|
||||||
|
Alpine weather, graceful fallback on failure.
|
||||||
|
|
||||||
|
All network calls degrade gracefully (existing `asyncio.timeout` + try/except).
|
||||||
|
|
||||||
|
## Out of scope / left as-is
|
||||||
|
|
||||||
|
- Past `call_history` summaries (genuinely happened in Deming).
|
||||||
|
- `main.py:3124` state-abbreviation map (generic, not show-locale).
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Caller Variety, Movable Callers, and Devon Search Fix
|
||||||
|
|
||||||
|
**Date:** 2026-06-02
|
||||||
|
**Status:** Approved, implementing
|
||||||
|
|
||||||
|
## Problems
|
||||||
|
|
||||||
|
1. **Callers feel formulaic** — too many deep moral dilemmas; the roster prompt
|
||||||
|
*enforces* it ("at least half moral dilemmas" + "NEVER generate callers who
|
||||||
|
are just enthusiastic about a hobby").
|
||||||
|
2. **Callers circle forever** — they don't take advice, restating the same moral
|
||||||
|
issue until the host cuts them off. The live dialog prompt gives a `situation`
|
||||||
|
+ `secret_want` but no mechanic to be moved, persuaded, or reach resolution.
|
||||||
|
3. **Devon's search fails** — SearXNG at `localhost:8888` is unreachable whenever
|
||||||
|
the laptop's Docker Desktop isn't running. Fragile.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### A. Roster variety — `backend/services/caller_gen.py` (`BATCH_SYSTEM_PROMPT`)
|
||||||
|
|
||||||
|
- Remove the absolute ban on hobby/job/story callers.
|
||||||
|
- Replace "at least half moral dilemmas" with an explicit distribution for a
|
||||||
|
10-caller roster (dilemmas still lead):
|
||||||
|
- **4–5** moral dilemmas / confessions / betrayals — keep the STAKES language.
|
||||||
|
- **2–3** storytellers & enthusiasts — a wild thing that happened, a fascinating
|
||||||
|
niche obsession or fact, a vivid slice-of-life. No deep dilemma required.
|
||||||
|
- **1–2** believers / chaos — earnest UFO/cryptid/conspiracy callers
|
||||||
|
(Coast-to-Coast sincerity) or a big eccentric personality on a rant.
|
||||||
|
- Keep the anti-collision rule, "reason they HAD to call tonight," and
|
||||||
|
"even lighter callers need energy and specificity."
|
||||||
|
|
||||||
|
### B. Movable callers — `backend/main.py` (`get_caller_prompt`)
|
||||||
|
|
||||||
|
Add a concise conversational-arc block for every caller:
|
||||||
|
- You have a position/want, but you're a real person — if Luke makes a good
|
||||||
|
point, genuinely react: agree, change your mind, soften, dig in, or decide.
|
||||||
|
- Don't restate a point/dilemma you've already made; the conversation must move.
|
||||||
|
You can reach a resolution, a decision, or an emotional turn. You are not
|
||||||
|
required to stay stuck.
|
||||||
|
|
||||||
|
Stacks on the existing "don't restate facts / move the story forward" rule.
|
||||||
|
Must keep `get_caller_prompt` output under the 3500-char test cap.
|
||||||
|
|
||||||
|
### C. SearXNG → NAS — `backend/config.py`, `backend/services/news.py`
|
||||||
|
|
||||||
|
- Deploy SearXNG to **mmgnas** as an always-on container (deploy-nas-docker).
|
||||||
|
- Add `searxng_url` to `config.py` (env-overridable), default to the NAS URL.
|
||||||
|
- `news.py` reads `settings.searxng_url` instead of the hardcoded constant;
|
||||||
|
`intern.py` imports `SEARXNG_URL` from `news.py`, so Devon, headlines, and
|
||||||
|
caller news-grounding all follow.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Run test suite (note: 2 pre-existing stale `test_caller_gen.py` failures).
|
||||||
|
- Live caller-gen batch → review the roster mix.
|
||||||
|
- Devon end-to-end: a `web_search` against the NAS SearXNG returns real results.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Press Release
|
||||||
|
|
||||||
|
**FOR IMMEDIATE RELEASE**
|
||||||
|
|
||||||
|
## New Podcast "Luke at the Roost" Blends AI-Generated Callers with Live Radio Format, Creating a One-of-a-Kind Late-Night Show
|
||||||
|
|
||||||
|
*Every caller is a unique AI character with a full backstory, personality, and speaking style — and real listeners can call in too*
|
||||||
|
|
||||||
|
**Lordsburg, NM — February 27, 2026** — "Luke at the Roost," a late-night call-in talk show hosted by Luke MacNeil, is redefining what a podcast can be by combining real-time AI-generated callers with traditional live radio. Now 23 episodes in, the show has built a loyal following with its unpredictable conversations, genuine emotional depth, and a cast of characters that listeners have come to know by name.
|
||||||
|
|
||||||
|
### A New Kind of Call-In Show
|
||||||
|
|
||||||
|
Unlike traditional podcasts that rely on scripted segments or pre-recorded interviews, Luke at the Roost generates every AI caller in real time. Each one arrives with a complete identity — name, age, job, hometown, speaking style, and a specific reason for calling. Some are nervous first-timers. Others are regulars who remember past conversations and reference previous episodes. The result is a show that feels like tuning into a real late-night radio station in the American Southwest.
|
||||||
|
|
||||||
|
"The whole point is that these conversations feel real," said MacNeil. "The AI doesn't read from a script. Neither do I. We're just two people talking, and sometimes it goes somewhere neither of us expected."
|
||||||
|
|
||||||
|
### Real Callers Welcome
|
||||||
|
|
||||||
|
The show isn't AI-only. Real listeners can call in live at **208-439-LUKE (208-439-5853)** or email submissions@lukeattheroost.com. The mix of AI-generated and human callers creates an atmosphere where the line between the two becomes part of the fun.
|
||||||
|
|
||||||
|
### Built from Scratch
|
||||||
|
|
||||||
|
The entire technical infrastructure — from the AI caller generator with over 1,000 unique calling reasons, to the multi-track recording system, voice synthesis pipeline, and automated post-production — was built custom by MacNeil. Episodes go through professional-grade audio processing including compression, music ducking, stereo mixing, and broadcast-standard loudness normalization before being automatically published to all major podcast platforms.
|
||||||
|
|
||||||
|
### Where to Listen
|
||||||
|
|
||||||
|
Luke at the Roost is available on all major podcast platforms:
|
||||||
|
|
||||||
|
- **Website**: https://lukeattheroost.com
|
||||||
|
- **Spotify**: https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF
|
||||||
|
- **Apple Podcasts**: https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848
|
||||||
|
- **YouTube**: https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-
|
||||||
|
- **Discord**: https://discord.gg/5CnQZxDM
|
||||||
|
|
||||||
|
### About MacNeil Media Group
|
||||||
|
|
||||||
|
MacNeil Media Group is an independent media company focused on experimental audio and AI-driven content. Luke at the Roost is its flagship production.
|
||||||
|
|
||||||
|
### Media Contact
|
||||||
|
|
||||||
|
Luke MacNeil
|
||||||
|
luke@lukeattheroost.com
|
||||||
|
https://lukeattheroost.com
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Summary (for submission forms)
|
||||||
|
|
||||||
|
**Headline**: New Podcast "Luke at the Roost" Blends AI-Generated Callers with Live Radio Format
|
||||||
|
|
||||||
|
**Summary (short)**: Luke at the Roost is a late-night call-in talk show where every caller is a unique AI-generated character with a full backstory and personality. Real listeners can call in too. 23 episodes in, the show has built a following with its unpredictable conversations and genuine emotional depth.
|
||||||
|
|
||||||
|
**Category**: Entertainment / Technology / Podcasting
|
||||||
|
|
||||||
|
**Tags**: podcast, AI, artificial intelligence, late-night radio, call-in show, talk show, entertainment technology, indie podcast, New Mexico
|
||||||
Reference in New Issue
Block a user