[object Object]

← back to Rentv 2026

feat(brief): Boomer 'Deal of the Day' audio brief (REPOINT feature #3, Steve-approved) — gen-deal-brief.mjs composes a factual script from the top deal (reuses acquirer) + ElevenLabs TTS in Boomer's voice (czUPGx5phxE6qpQ46nRS), with a top-deal-change spend guard (~$0.15/day not per-cron) + DRY_RUN; brief.html gains a graceful audio player. ALSO fixes a real summarize() bug the brief surfaced: the sentence splitter broke decimals ('$1.625'→'$1. 625') on every deal card — now splits only on a terminator+space+capital. +1 test, 64/0. Generated 1 brief ($0.147, local).

48878dbe4c9ef2e172e5db43153cbb245d6d1031 · 2026-08-06 16:09:24 -0700 · Steve

Files touched

Diff

commit 48878dbe4c9ef2e172e5db43153cbb245d6d1031
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 6 16:09:24 2026 -0700

    feat(brief): Boomer 'Deal of the Day' audio brief (REPOINT feature #3, Steve-approved) — gen-deal-brief.mjs composes a factual script from the top deal (reuses acquirer) + ElevenLabs TTS in Boomer's voice (czUPGx5phxE6qpQ46nRS), with a top-deal-change spend guard (~$0.15/day not per-cron) + DRY_RUN; brief.html gains a graceful audio player. ALSO fixes a real summarize() bug the brief surfaced: the sentence splitter broke decimals ('$1.625'→'$1. 625') on every deal card — now splits only on a terminator+space+capital. +1 test, 64/0. Generated 1 brief ($0.147, local).
---
 .gitignore                 |  3 ++
 public/brief.html          | 21 +++++++++++
 scripts/gen-deal-brief.mjs | 93 ++++++++++++++++++++++++++++++++++++++++++++++
 scripts/lib/deal-parse.mjs |  6 ++-
 test/deals/parse.test.mjs  |  7 ++++
 5 files changed, 129 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index e489498b..eda8a131 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,6 @@ data/search-index.jsonl
 data/search-index-meta.json
 ops/*.log
 ops/*.err
+
+# Generated Boomer audio briefs (regenerable, rsync-excluded like other runtime data)
+public/audio/
diff --git a/public/brief.html b/public/brief.html
index 512ff3dd..863fed17 100644
--- a/public/brief.html
+++ b/public/brief.html
@@ -112,6 +112,27 @@
     <a href="/subscribe"><button>✉️ Subscribe</button></a>
   </div>
 
+  <!-- Boomer "Deal of the Day" audio brief — hidden until an mp3 exists (graceful empty state) -->
+  <div class="boomcast" id="boomcast" style="display:none;margin:18px 0;padding:15px 18px;border:1px solid var(--line,#e4e7ea);border-radius:14px;background:linear-gradient(180deg,#fff,#fafbfc)">
+    <div style="display:flex;align-items:center;gap:13px;flex-wrap:wrap">
+      <div style="font-size:26px" aria-hidden="true">🎧</div>
+      <div style="flex:1;min-width:180px">
+        <div style="font-size:10px;font-weight:800;letter-spacing:.14em;text-transform:uppercase;color:#c8102e">Boomer's Deal of the Day</div>
+        <a id="bc-title" href="/deals" style="display:block;font-weight:700;font-size:15px;margin-top:2px;color:inherit;text-decoration:none">—</a>
+      </div>
+      <audio id="bc-audio" controls preload="none" style="height:38px;max-width:100%"></audio>
+    </div>
+  </div>
+  <script>(function(){
+    fetch('/audio/deal-brief.json',{cache:'no-store'}).then(function(r){return r.ok?r.json():null;}).then(function(m){
+      if(!m||m.dry_run) return;
+      var el=document.getElementById('boomcast'); if(!el) return;
+      var t=document.getElementById('bc-title'); t.textContent=m.title||'Today’s top deal'; if(m.url)t.href=m.url;
+      document.getElementById('bc-audio').src='/audio/deal-brief.mp3';
+      el.style.display='block';
+    }).catch(function(){});
+  })();</script>
+
   <div class="snapshot" id="snapshot"></div>
 
   <div class="sec"><div class="rule">Top Story</div><div id="lead"></div></div>
diff --git a/scripts/gen-deal-brief.mjs b/scripts/gen-deal-brief.mjs
new file mode 100644
index 00000000..c1982bf6
--- /dev/null
+++ b/scripts/gen-deal-brief.mjs
@@ -0,0 +1,93 @@
+#!/usr/bin/env node
+// gen-deal-brief.mjs — Boomer "Deal of the Day" audio brief (REPOINT feature #3, Steve-approved 2026-08-06).
+// Picks the top deal by amount, composes a FACTUAL ~450-char script (reusing verified deal fields incl. the
+// new `acquirer`), synthesizes it in Boomer's cloned voice via ElevenLabs, caches the mp3 + metadata.
+//
+// SPEND GUARD (the memo's key guardrail): regenerates ONLY when the top deal CHANGES (dedupe on deal id) —
+// never per-cron — so daily cost stays ~$0.15, not ~$10. `DRY_RUN=1` composes the script + writes metadata
+// with NO TTS call ($0). `--force` overrides the dedupe.
+//
+// Boomer is the on-air PERSONA ("This is Boom…") — never "Bloom" in any customer-facing text. The script is
+// factual-only (no editorializing) so an auto-published brief can't misstate a deal.
+import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const DATA = join(ROOT, 'data');
+const AUDIO = join(ROOT, 'public', 'audio');
+const VOICE_ID = process.env.RENTV_VOICE_ID || 'czUPGx5phxE6qpQ46nRS';
+const RATE_PER_1K = 0.30; // ElevenLabs Creator-tier estimate, $/1000 chars
+const FORCE = process.argv.includes('--force');
+
+function elevenKey() {
+  if (process.env.ELEVENLABS_API_KEY) return process.env.ELEVENLABS_API_KEY.trim().replace(/^["']|["']$/g, '');
+  const candidates = [join(ROOT, '.env'), join(process.env.HOME || '', 'Projects/secrets-manager/.env')];
+  for (const f of candidates) {
+    try { const m = readFileSync(f, 'utf8').match(/^ELEVENLABS_API_KEY=(.+)$/m); if (m) return m[1].trim().replace(/^["']|["']$/g, ''); } catch {}
+  }
+  return '';
+}
+
+function pickTopDeal(deals) {
+  return deals.filter(d => typeof d.amount === 'number' && d.amount > 0).sort((a, b) => b.amount - a.amount)[0] || null;
+}
+
+// Factual one-paragraph brief keyed to the verified deal fields — no invented detail.
+export function script(d) {
+  const who = (d.acquirer && String(d.acquirer).trim()) || 'A major investor';
+  const amt = d.amount_label || (d.amount ? `$${Math.round(d.amount / 1e6)} million` : '');
+  const mkt = [d.city, d.state].filter(Boolean).join(', ');
+  const type = d.property_type ? String(d.property_type).toLowerCase() : 'commercial real estate';
+  // Defensive: repair a decimal that a stale summary may have split ("$1. 625" → "$1.625") so the
+  // synthesized audio never speaks a broken number. (The root fix is in summarize(); this guards TTS
+  // input against pre-fix cached data.)
+  const summary = d.summary ? String(d.summary).replace(/(\d)\.\s+(?=\d)/g, '$1.') : '';
+  let s = `This is Boom with RENTV, and here's today's top deal. ${d.title}.`;
+  if (amt) s += ` ${who} in a ${amt} transaction${mkt ? ` in ${mkt}` : ''}.`;
+  if (summary) s += ` ${summary}`;
+  s += ` It's the kind of ${type} activity moving the Western U.S. market. For the full story, visit rentv dot com.`;
+  return s.replace(/\s+/g, ' ').trim().slice(0, 900); // hard length cap = cost cap
+}
+
+async function tts(text, out) {
+  const key = elevenKey();
+  if (!key) throw new Error('ELEVENLABS_API_KEY missing');
+  const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${VOICE_ID}?output_format=mp3_44100_128`, {
+    method: 'POST',
+    headers: { 'xi-api-key': key, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ text, model_id: 'eleven_turbo_v2_5', voice_settings: { stability: 0.5, similarity_boost: 0.8, style: 0.15 } }),
+  });
+  if (!res.ok) throw new Error(`ElevenLabs ${res.status}: ${(await res.text()).slice(0, 200)}`);
+  writeFileSync(out, Buffer.from(await res.arrayBuffer()));
+}
+
+async function main() {
+  const deals = JSON.parse(readFileSync(join(DATA, 'deals.json'), 'utf8')).deals || [];
+  const top = pickTopDeal(deals);
+  if (!top) { console.log('no priced deal — skip (nothing to narrate)'); return; }
+  if (!existsSync(AUDIO)) mkdirSync(AUDIO, { recursive: true });
+  const metaPath = join(AUDIO, 'deal-brief.json');
+  const mp3Path = join(AUDIO, 'deal-brief.mp3');
+  const prev = existsSync(metaPath) ? JSON.parse(readFileSync(metaPath, 'utf8')) : null;
+  // Skip (no spend) only if the SAME top deal already has a REAL mp3 on disk — a prior DRY_RUN (no audio)
+  // must not poison the cache into skipping the real synthesis.
+  if (prev && prev.deal_id === top.id && !prev.dry_run && existsSync(mp3Path) && !FORCE) {
+    console.log(`top deal unchanged (${top.id}) + mp3 exists — SKIP, no spend`); return;
+  }
+
+  const text = script(top);
+  const chars = text.length;
+  const est = (chars / 1000) * RATE_PER_1K;
+  console.log(`top deal ${top.id}: "${top.title.slice(0, 54)}"`);
+  console.log(`script (${chars} chars): ${text}`);
+  console.log(`est cost: $${est.toFixed(3)} (${chars} chars @ $${RATE_PER_1K}/1k)`);
+
+  const meta = { deal_id: top.id, title: top.title, acquirer: top.acquirer || null, amount_label: top.amount_label || null, url: top.url || null, script: text, chars, generated_at: new Date().toISOString() };
+  if (process.env.DRY_RUN) { meta.dry_run = true; writeFileSync(metaPath, JSON.stringify(meta, null, 1)); console.log('DRY_RUN=1 → wrote metadata, NO TTS call, $0'); return; }
+  await tts(text, join(AUDIO, 'deal-brief.mp3'));
+  writeFileSync(metaPath, JSON.stringify(meta, null, 1));
+  console.log(`✓ /public/audio/deal-brief.mp3 written · actual ~$${est.toFixed(3)}`);
+}
+
+main().catch(e => { console.error('gen-deal-brief failed:', e.message); process.exit(1); });
diff --git a/scripts/lib/deal-parse.mjs b/scripts/lib/deal-parse.mjs
index 359cd7ea..1976115f 100644
--- a/scripts/lib/deal-parse.mjs
+++ b/scripts/lib/deal-parse.mjs
@@ -159,7 +159,11 @@ export function summarize(title, body) {
   const ti = b.indexOf(title);
   if (ti >= 0) b = b.slice(ti + title.length);
   b = b.replace(/^\s*\d{1,2}\/\d{1,2}\/\d{2,4}\s*/, '').trim();
-  const sentences = b.match(/[^.!?]+[.!?]+/g) || [b];
+  // Split on a terminator followed by whitespace + a capital/quote — a REAL sentence boundary — so a
+  // decimal ("$1.625 bil") or abbreviation ("U.S.") is not mistaken for a sentence end. The old
+  // /[^.!?]+[.!?]+/g split "$1.625" into "$1." + "625" (customer-facing "$1. 625 bil"). Falls back to the
+  // whole body when there's no clean boundary.
+  const sentences = b.split(/(?<=[.!?])\s+(?=[A-Z"'“])/).filter(Boolean);
   // Collapse whitespace runs to a single space: the sentence class [^.!?]+ greedily absorbs the
   // space(s) AFTER the prior sentence's period, so each captured sentence carries leading whitespace
   // and join(' ') then yields "end.  Start" (2–3 spaces). 30% of the live corpus's summaries showed
diff --git a/test/deals/parse.test.mjs b/test/deals/parse.test.mjs
index 7e27c0a9..ae7cfa58 100644
--- a/test/deals/parse.test.mjs
+++ b/test/deals/parse.test.mjs
@@ -191,6 +191,13 @@ test('parseAcquirer: null on ASSET-fronted / place-fronted / passive titles (nev
 test('summarize: empty body → empty string (never throws)', () => {
   assert.equal(summarize('Any Title', ''), '');
 });
+test('summarize: a decimal / abbreviation is NOT split as a sentence boundary', () => {
+  // "$1.625" and "U.S." used to split the decimal → "$1. 625 bil" on the deal card (surfaced by the audio brief).
+  assert.equal(
+    summarize('X', 'X A vehicle acquired an 11-property portfolio for $1.625 bil in the U.S. market. Next detail here.'),
+    'A vehicle acquired an 11-property portfolio for $1.625 bil in the U.S. market. Next detail here.',
+  );
+});
 test('summarize: collapses multi-space / tab / newline runs from the fetched body (30% of live corpus)', () => {
   // The body itself carries double spaces + a newline (real fetched-article artifact); output must be clean.
   assert.equal(

← 21845887 auto-data-snapshot: 2026-08-06T16:03:07 (7 data files) — dat  ·  back to Rentv 2026  ·  nav: surface /firm (💼 Active Dealmakers) in the site-wide E fd1945d5 →