// PresentationPage — fullscreen 13-slide deck. Chinese-only by design. // Entry: URL hash #presentation. Exit: Escape (clears hash). // Reads window.RELEASE_LOG / STATS_* / TAG_LABELS from src/releaseLog.jsx. const TOTAL_SLIDES = 13; // Inline case study summaries (3 lines each: 問題 / 轉折 / 學到的事). // Sourced from docs/case-studies/ but written here as constants — no fetch. const CASE_STUDIES = [ { file: 'transcription-queue-discussion.md', title: '把模糊的「排程」需求拆成 6 個明確決策', problem: '後端有 ShowSchedule 表存頻率/時間,但專案完全沒有 cron。死資料放了兩週沒人動。', turn: '使用者沒順 Claude 的技術假設答,而是退回畫出自己的 mental model:節目 → 集數 →(更新清單 / 轉錄)兩個動詞。', lesson: '討論卡住時,從技術假設退回使用者情境;Claude 主動 challenge 並標推薦,使用者只需 confirm 不用每題從零想。', }, { file: 'sync-naming-redesign.md', title: '「同步」這個詞讓使用者搞不清楚按鈕會做什麼', problem: '「同步集數」(只抓 RSS)和「同步所有」(會燒 OpenAI 額度)語意混在一起,使用者點下去無法預期會不會花錢。', turn: '使用者反問:「目前的同步,這件事會去執行什麼動作?」Claude 把所有按鈕的真實行為列表攤開,根因立刻清楚。', lesson: '一個動詞被掛在性質完全不同的兩件事上時,UX 必然出問題。把名字和行為對照列出,就能找到該分裂的地方。', }, { file: 'local-vs-prod-verification-violation.md', title: '明明寫好不准 local 驗,Claude 還是默默開了 http.server', problem: '規則寫了「禁止 local 驗證」,Claude 卻自我合理化「我沒用 mock 資料只是繞 CORS」就跑了 python3 -m http.server。', turn: '使用者沒罵人,只說「我之前好像有提過類似要求」—— 把「不是新規則,是你忘了用」這個訊號傳給 Claude。', lesson: '規則要寫清楚 why 與「即使 X 也不行」的延伸條款;否則 AI 會字面化解讀並自我合理化。', }, ]; const NEXT_STEPS = [ '使用量統計 Dashboard(token / cost 計價)', '正式帳號驗證系統(目前是寫死的示範帳密)', 'Pre-built base image(build 時間 10min → 30sec)', 'pg_dump 定期備份', 'API Keys tab 後端連動(目前是 mock)', ]; // Reusable slide chrome — fullscreen card with footer counter & hint. const SlideShell = ({ slideIndex, children, accent = TOKEN.accent }) => (
{children}
{/* Footer */}
← → 切換 | Space 下一張 | Esc 退出 {slideIndex + 1} / {TOTAL_SLIDES}
); // ── Slide 0: Cover ── const SlideCover = () => (
PODCASTRAG · 系統介紹

一個 Podcast RAG 系統的
成長軌跡

從 24 個 archived changes 看見產品如何一塊一塊長出來

); // ── Slide 1: System intro ── const SlideIntro = () => (
WHAT IS PODCASTRAG

讓使用者用對話查 Podcast 內容,
不用聽完整集就找得到關鍵段落。

{[ { icon: 'rss', title: 'RSS → 逐字稿', desc: '貼一個 RSS URL,系統自動抓集數、用 Whisper 轉錄成帶時間戳的逐字稿。' }, { icon: 'brain', title: '語意檢索 + RAG', desc: '逐字稿切 chunk → embedding → pgvector 搜尋 → LLM 帶引用回答。' }, { icon: 'play', title: '點擊跳轉時間段', desc: '回答中的引用 Badge 點下去,直接跳到逐字稿對應時間點。' }, ].map(item => (
{item.title}
{item.desc}
))}
); // ── Slide 2: Architecture diagram (pure div) ── const ArchBox = ({ label, sub, color = TOKEN.accent, width = 140 }) => (
{label}
{sub}
); const ArchArrow = ({ width = 60 }) => (
); const SlideArchitecture = () => (
ARCHITECTURE

系統架構

部署於 Zeabur + Linode SIN VPS / LLM 走 Zeabur AI Hub(OpenAI-compatible)

); // ── Slides 3–6: Milestones (data-driven from RELEASE_LOG) ── const SlideMilestone = ({ milestone }) => { const entries = RELEASE_LOG .filter(e => e.milestone === milestone) .slice() .sort((a, b) => (a.date < b.date ? -1 : 1)); const label = MILESTONE_LABELS[milestone] ? MILESTONE_LABELS[milestone].zh : milestone; return (
MILESTONE · {milestone.toUpperCase()}

{label}

{entries[0]?.date} – {entries[entries.length - 1]?.date} | 共 {entries.length} 項更新
{entries.map(e => (
{e.date}
{TAG_LABELS[e.tag].zh}
{e.title.zh}
))}
); }; // ── Slide 7: Stats ── const SlideStats = () => { const [stats, setStats] = React.useState(null); React.useEffect(() => { let cancelled = false; apiFetch('/stats').then(res => { if (cancelled || !res.ok) return; return res.json(); }).then(body => { if (cancelled || !body) return; setStats(body); }).catch(() => {}); return () => { cancelled = true; }; }, []); const epCount = stats ? stats.episodes_completed : STATS_EPISODES_COUNT; const chunkCount = stats ? stats.transcript_chunks : STATS_VECTORS_COUNT; const cards = [ { value: STATS_CHANGES_COUNT, label: 'Archived Changes', sub: '從 4/19 到 5/01' }, { value: epCount.toLocaleString(), label: '已轉錄集數', sub: '使用 OpenAI Whisper' }, { value: chunkCount.toLocaleString(), label: '向量索引筆數', sub: 'pgvector 1536-dim' }, ]; return (
BY THE NUMBERS

數字成長

截至 {STATS_AS_OF}
{cards.map(c => (
{c.value}
{c.label}
{c.sub}
))}
); }; // ── Slides 8–10: Case studies ── const SlideCaseStudy = ({ index }) => { const cs = CASE_STUDIES[index]; return (
過程心得 · {index + 1} / {CASE_STUDIES.length}

{cs.title}

{[ { tag: '問題', body: cs.problem, color: TOKEN.danger }, { tag: '轉折', body: cs.turn, color: TOKEN.warning }, { tag: '學到的事', body: cs.lesson, color: TOKEN.success }, ].map(row => (
{row.tag}
{row.body}
))}
docs/case-studies/{cs.file}
); }; // ── Slide 11: Next steps ── const SlideNext = () => (
WHAT'S NEXT

接下來

{NEXT_STEPS.map((step, idx) => (
{idx + 1}
{step}
))}
); // ── Slide 12: Closing ── const SlideClosing = () => (

謝謝聆聽

PodcastRAG · podcastrag.zeabur.app

"從 24 個小決定堆出來的系統。"

); // ── Main ── const PresentationPage = () => { const [slideIndex, setSlideIndex] = React.useState(0); React.useEffect(() => { const onKey = (e) => { if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'Spacebar') { e.preventDefault(); setSlideIndex(i => Math.min(TOTAL_SLIDES - 1, i + 1)); } else if (e.key === 'ArrowLeft') { e.preventDefault(); setSlideIndex(i => Math.max(0, i - 1)); } else if (e.key === 'Escape') { e.preventDefault(); if (window.location.hash) { history.replaceState(null, '', window.location.pathname + window.location.search); window.dispatchEvent(new HashChangeEvent('hashchange')); } } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, []); let slide; if (slideIndex === 0) slide = ; else if (slideIndex === 1) slide = ; else if (slideIndex === 2) slide = ; else if (slideIndex === 3) slide = ; else if (slideIndex === 4) slide = ; else if (slideIndex === 5) slide = ; else if (slideIndex === 6) slide = ; else if (slideIndex === 7) slide = ; else if (slideIndex === 8) slide = ; else if (slideIndex === 9) slide = ; else if (slideIndex === 10) slide = ; else if (slideIndex === 11) slide = ; else slide = ; return {slide}; }; Object.assign(window, { PresentationPage });