+<% end %>
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add app/views/meetings/show.html.erb
+git commit -m "feat: show transcript document with Watch Recording link in documents section"
+```
+
+---
+
+### Task 9: Run full test suite and lint
+
+- [ ] **Step 1: Run full test suite**
+
+Run: `bin/rails test`
+Expected: ALL PASS
+
+- [ ] **Step 2: Run linter**
+
+Run: `bin/rubocop`
+Expected: No new offenses. Fix any that appear in files you modified.
+
+- [ ] **Step 3: Run CI checks**
+
+Run: `bin/ci`
+Expected: PASS
+
+- [ ] **Step 4: Fix any issues found, then commit fixes**
+
+If any fixes needed:
+```bash
+git add -A
+git commit -m "fix: address lint/test issues from transcript feature"
+```
+
+---
+
+### Task 10: Create PR
+
+- [ ] **Step 1: Push branch and create PR**
+
+```bash
+git push -u origin feature/youtube-transcript-ingestion
+gh pr create --title "Add YouTube transcript ingestion for same-day council meeting summaries" --body "$(cat <<'EOF'
+## Summary
+- Ingests YouTube auto-generated captions from council meeting recordings
+- Produces same-day preliminary summaries when official minutes aren't available yet
+- Enriches minutes-based summaries with transcript context when minutes arrive
+- Shows a visible banner on meeting pages when summary is transcript-sourced
+
+## Design
+See `docs/superpowers/specs/2026-04-09-youtube-transcript-ingestion-design.md`
+
+## Changes
+- **New jobs:** `DiscoverTranscriptsJob` (finds YouTube videos for recent council meetings), `DownloadTranscriptJob` (fetches auto-captions, creates MeetingDocument)
+- **Modified:** `SummarizeMeetingJob` (transcript priority tier, supplementary context), `DiscoverMeetingsJob` (triggers transcript discovery), `MeetingsController` (finds transcript summaries), meeting show view (transcript banner + document display)
+- **Infrastructure:** `yt-dlp` added to Dockerfile
+
+## Test plan
+- [ ] Run `bin/rails test` — all tests pass
+- [ ] Run `bin/ci` — lint, security, audit all pass
+- [ ] Manually test with a real YouTube video ID to verify yt-dlp caption download works
+- [ ] Verify transcript banner renders correctly on meeting show page
+- [ ] Verify banner disappears when minutes-based summary exists
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)
+EOF
+)"
+```
diff --git a/docs/superpowers/specs/2026-04-09-youtube-transcript-ingestion-design.md b/docs/superpowers/specs/2026-04-09-youtube-transcript-ingestion-design.md
new file mode 100644
index 0000000..3b3a47e
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-09-youtube-transcript-ingestion-design.md
@@ -0,0 +1,168 @@
+# YouTube Transcript Ingestion Design
+
+**Date:** 2026-04-09
+**Status:** Approved
+
+## Problem
+
+Council meetings and work sessions are recorded and posted to YouTube within hours, but the site currently waits weeks for official minutes before publishing summaries. Residents get nothing in the gap.
+
+## Solution
+
+Ingest YouTube auto-generated captions as a new `transcript` document type. Use transcripts to produce same-day preliminary summaries, then enrich the authoritative minutes-based summaries with transcript context when minutes arrive later.
+
+**Core principle:** The transcript is a **supplement**, not a replacement. It never overrides official sources. The existing pipeline is untouched unless a transcript happens to be available.
+
+## Data Model
+
+### MeetingDocument
+
+No new tables or columns. Transcripts use the existing `meeting_documents` table:
+
+| Field | Value |
+|-------|-------|
+| `document_type` | `"transcript"` |
+| `source_url` | YouTube video URL (e.g., `https://www.youtube.com/watch?v=S8rW22zizHc`) |
+| `extracted_text` | Plain text (SRT timestamps stripped) |
+| `file` | Raw SRT file (Active Storage attachment) |
+| `fetched_at` | When the transcript was downloaded |
+| `text_chars` | Character count of extracted text |
+| `text_quality` | `"auto_transcribed"` |
+
+### Meeting#document_status
+
+Updated priority: `:minutes` > `:packet` > `:transcript` > `:agenda` > `:none`.
+
+### MeetingSummary.generation_data
+
+New `source_type` key in the existing JSON column:
+- `"transcript"` — preliminary summary from transcript only
+- `"minutes"` — authoritative summary from minutes only
+- `"minutes_with_transcript"` — authoritative summary enriched with transcript context
+
+## Job Chain
+
+### Scrapers::DiscoverTranscriptsJob
+
+**Trigger:** Enqueued by `DiscoverMeetingsJob` at the end of its run.
+
+**Logic:**
+1. Query for Meeting records where:
+ - `body_name` matches Council or Work Session (the only recorded meetings)
+ - `starts_at` within the last 48 hours
+ - No existing `transcript` document
+2. Call `yt-dlp --flat-playlist --print "%(id)s | %(title)s"` on the channel URL
+3. Parse each video title with regex to extract the meeting date:
+ - Primary pattern: `/for \w+, (.+)$/` (handles "Two Rivers City Council Meeting for Monday, April 6, 2026")
+ - Unmatched titles are logged as warnings and skipped
+4. Match parsed date + body keyword ("Council", "Work Session") to Meeting records
+5. Enqueue `Documents::DownloadTranscriptJob` for each match
+
+**Channel URL constant:**
+```ruby
+YOUTUBE_CHANNEL_URL = "https://www.youtube.com/@Two_Rivers_WI/streams"
+```
+
+**Idempotency:** Skips meetings that already have a transcript document.
+
+### Documents::DownloadTranscriptJob
+
+**Input:** Meeting ID, YouTube video URL.
+
+**Logic:**
+1. Check for existing transcript document on the meeting (idempotency guard)
+2. Call `yt-dlp --write-auto-sub --sub-lang en --sub-format srt --skip-download` via `Open3.capture3`
+3. Parse SRT to plain text: strip sequence numbers, timestamps (`HH:MM:SS,mmm --> HH:MM:SS,mmm`), blank lines
+4. Create `MeetingDocument` with `document_type: "transcript"`:
+ - Attach raw SRT file
+ - Store plain text in `extracted_text`
+ - Set `text_quality: "auto_transcribed"`, `text_chars`, `fetched_at`, `source_url`
+5. If the meeting has no minutes-based summary yet, enqueue `SummarizeMeetingJob(meeting.id)`
+
+**Shell execution:** `Open3.capture3` pattern, consistent with `pdftotext`/`tesseract` usage in existing jobs. Uses `Dir.mktmpdir` for `yt-dlp` output files, cleaned up after processing.
+
+**Failure handling:** Log error and exit on `yt-dlp` failure. Next day's discovery run retries automatically.
+
+## Summarization Changes
+
+### SummarizeMeetingJob
+
+**Updated document priority:** minutes > transcript > packet.
+
+**When transcript is the best available source (no minutes):**
+- Use transcript `extracted_text` as primary input
+- Adjust AI prompt: "Summarize the discussion from this meeting recording transcript" (vs. "Summarize the official minutes")
+- Store `"source_type": "transcript"` in `MeetingSummary.generation_data`
+- The resulting summary is preliminary — accurate to the recording but not the official record
+
+**When minutes arrive and re-trigger the job:**
+- Minutes remain primary input (existing behavior unchanged)
+- Transcript `extracted_text` appended as supplementary context: "Additional context from the meeting recording transcript" (15K char limit)
+- Store `"source_type": "minutes_with_transcript"` in `generation_data`
+- If no transcript exists, behavior is identical to today (`"source_type": "minutes"`)
+
+**No changes to other extraction jobs.** `ExtractTopicsJob`, `ExtractVotesJob`, and `ExtractCommitteeMembersJob` continue to trigger only from minutes/agenda.
+
+## Meeting Show Page — Transcript Banner
+
+**Condition:** `MeetingSummary` exists with `generation_data["source_type"] == "transcript"` and no `minutes_pdf` document on the meeting.
+
+**Location:** Top of meeting show page, between the meeting meta section and the headline section.
+
+**Appearance:** A distinct, noticeable callout — NOT the warm theme (too subtle against the cream background). Use a cool or contrasting accent treatment so it reads as informational/cautionary:
+
+> This summary is based on the meeting's video recording. It will be updated when official minutes are published.
+
+**Removal:** Automatic. When minutes arrive and `SummarizeMeetingJob` regenerates the summary with `source_type: "minutes"` or `"minutes_with_transcript"`, the banner condition is no longer met. No manual action required.
+
+## Infrastructure
+
+### Dockerfile
+
+Add `yt-dlp` as a system dependency. Download the standalone binary (no Python required):
+
+```dockerfile
+RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
+ chmod +x /usr/local/bin/yt-dlp
+```
+
+### Schedule
+
+No new entry in `config/recurring.yml`. `DiscoverTranscriptsJob` is enqueued by `DiscoverMeetingsJob` (daily at 11pm). The chain is:
+
+```
+DiscoverMeetingsJob (11pm daily)
+ → at end of run, enqueues DiscoverTranscriptsJob
+ → for each matched video, enqueues DownloadTranscriptJob
+ → if no minutes summary, enqueues SummarizeMeetingJob
+```
+
+## Scope Boundaries
+
+**In scope:**
+- Two new jobs (discover + download)
+- `SummarizeMeetingJob` modifications (transcript priority, supplementary context, source_type tracking)
+- `Meeting#document_status` update
+- Transcript banner on meeting show page
+- Dockerfile update for `yt-dlp`
+- `DiscoverMeetingsJob` change to enqueue transcript discovery
+
+**Out of scope:**
+- Speaker diarization / attribution
+- Vote or member extraction from transcripts
+- Admin UI for manual video-to-meeting linking
+- Backfilling old videos (manual rake task, future work)
+- Whisper / local transcription (YouTube auto-captions are sufficient)
+- Video download or storage (captions only, `--skip-download`)
+
+## YouTube Title Matching
+
+Observed title patterns from the channel (`@Two_Rivers_WI/streams`):
+
+| Pattern | Example | Frequency |
+|---------|---------|-----------|
+| Standard | `Two Rivers City Council Meeting for Monday, April 6, 2026` | ~90% |
+| Work Session | `Two Rivers City Council Work Session for Monday, March 30, 2026` | ~8% |
+| Special | `Joint Meeting of Plan Commission, EAB, Advisory Recreation Board, & City Council 7/23/2025` | ~2% |
+
+The regex handles the standard and work session patterns. Special/joint meeting titles are logged and skipped. The discovery job only looks for videos matching meetings in the last 48 hours, not the full back-catalog.
diff --git a/test/jobs/documents/download_transcript_job_test.rb b/test/jobs/documents/download_transcript_job_test.rb
new file mode 100644
index 0000000..9a118ca
--- /dev/null
+++ b/test/jobs/documents/download_transcript_job_test.rb
@@ -0,0 +1,164 @@
+require "test_helper"
+require "ostruct"
+require "open3"
+
+module Documents
+ class DownloadTranscriptJobTest < ActiveJob::TestCase
+ include ActiveJob::TestHelper
+
+ SAMPLE_SRT = <<~SRT
+ 1
+ 00:00:01,000 --> 00:00:03,000
+ Welcome to the city council meeting.
+
+ 2
+ 00:00:04,000 --> 00:00:06,500
+ Tonight we will discuss the budget proposal.
+
+ 3
+ 00:00:07,000 --> 00:00:09,000
+ Public input is now open.
+
+ SRT
+
+ def setup
+ @meeting = Meeting.create!(
+ body_name: "City Council",
+ meeting_type: "Regular",
+ starts_at: Time.zone.local(2026, 3, 15, 18, 0, 0),
+ status: "held",
+ detail_page_url: "http://example.com/meetings/transcript-test-#{SecureRandom.hex(4)}"
+ )
+ @video_url = "https://www.youtube.com/watch?v=abc123"
+ end
+
+ # Stubs Dir.mktmpdir("transcript") to yield a real tmpdir that already has the SRT file.
+ # Restores Dir.mktmpdir after the block.
+ def stub_yt_dlp(srt_content)
+ Dir.mktmpdir("test-transcript") do |tmpdir|
+ srt_path = File.join(tmpdir, "video.en.srt")
+ File.write(srt_path, srt_content)
+
+ original_mktmpdir = Dir.method(:mktmpdir)
+ Dir.define_singleton_method(:mktmpdir) do |*args, &block|
+ if args.first == "transcript"
+ block.call(tmpdir)
+ else
+ original_mktmpdir.call(*args, &block)
+ end
+ end
+
+ Open3.stub :capture3, [ "", "", OpenStruct.new(success?: true) ] do
+ yield
+ end
+ ensure
+ Dir.define_singleton_method(:mktmpdir, original_mktmpdir)
+ end
+ end
+
+ # -----------------------------------------------------------------------
+ # Test 1: creates MeetingDocument with correct attributes and file attached
+ # -----------------------------------------------------------------------
+ test "downloads transcript and creates MeetingDocument" do
+ stub_yt_dlp(SAMPLE_SRT) do
+ assert_difference "MeetingDocument.count", 1 do
+ DownloadTranscriptJob.perform_now(@meeting.id, @video_url)
+ end
+ end
+
+ doc = @meeting.meeting_documents.find_by!(document_type: "transcript")
+ assert_equal @video_url, doc.source_url
+ assert_equal "auto_transcribed", doc.text_quality
+ assert_not_includes doc.extracted_text, "00:00:01,000 --> 00:00:03,000", "SRT timestamps must be stripped"
+ refute_match(/^\d+\s*$/, doc.extracted_text, "SRT sequence numbers must be stripped")
+ assert_includes doc.extracted_text, "Welcome to the city council meeting."
+ assert_includes doc.extracted_text, "Tonight we will discuss the budget proposal."
+ assert_operator doc.text_chars, :>, 0
+ assert_not_nil doc.fetched_at
+ assert doc.file.attached?, "SRT file should be attached"
+ end
+
+ # -----------------------------------------------------------------------
+ # Test 2: skips if transcript document already exists
+ # -----------------------------------------------------------------------
+ test "skips if meeting already has a transcript document" do
+ MeetingDocument.create!(
+ meeting: @meeting,
+ document_type: "transcript",
+ source_url: @video_url,
+ text_quality: "auto_transcribed",
+ extracted_text: "existing transcript"
+ )
+
+ stub_yt_dlp(SAMPLE_SRT) do
+ assert_no_difference "MeetingDocument.count" do
+ DownloadTranscriptJob.perform_now(@meeting.id, @video_url)
+ end
+ end
+ end
+
+ # -----------------------------------------------------------------------
+ # Test 3: enqueues SummarizeMeetingJob when no minutes_recap summary
+ # -----------------------------------------------------------------------
+ test "enqueues SummarizeMeetingJob when no minutes_recap summary exists" do
+ stub_yt_dlp(SAMPLE_SRT) do
+ assert_enqueued_with(job: SummarizeMeetingJob, args: [ @meeting.id ]) do
+ DownloadTranscriptJob.perform_now(@meeting.id, @video_url)
+ end
+ end
+ end
+
+ # -----------------------------------------------------------------------
+ # Test 4: does not enqueue SummarizeMeetingJob when minutes_recap exists
+ # -----------------------------------------------------------------------
+ test "does not enqueue SummarizeMeetingJob when minutes_recap summary exists" do
+ MeetingSummary.create!(
+ meeting: @meeting,
+ summary_type: "minutes_recap",
+ content: "Existing minutes recap"
+ )
+
+ stub_yt_dlp(SAMPLE_SRT) do
+ assert_no_enqueued_jobs(only: SummarizeMeetingJob) do
+ DownloadTranscriptJob.perform_now(@meeting.id, @video_url)
+ end
+ end
+ end
+
+ # -----------------------------------------------------------------------
+ # Test 5: handles yt-dlp failure gracefully (no document created)
+ # -----------------------------------------------------------------------
+ test "handles yt-dlp failure gracefully without creating a document" do
+ original_mktmpdir = Dir.method(:mktmpdir)
+ Dir.define_singleton_method(:mktmpdir) do |*args, &block|
+ if args.first == "transcript"
+ Dir.mktmpdir("test-transcript-fail") do |tmpdir|
+ # No SRT file written — yt-dlp "failed"
+ block.call(tmpdir)
+ end
+ else
+ original_mktmpdir.call(*args, &block)
+ end
+ end
+
+ begin
+ Open3.stub :capture3, [ "", "ERROR: Unable to download", OpenStruct.new(success?: false) ] do
+ assert_no_difference "MeetingDocument.count" do
+ DownloadTranscriptJob.perform_now(@meeting.id, @video_url)
+ end
+ end
+ ensure
+ Dir.define_singleton_method(:mktmpdir, original_mktmpdir)
+ end
+ end
+
+ # -----------------------------------------------------------------------
+ # Test 6: rejects invalid video URLs
+ # -----------------------------------------------------------------------
+ test "rejects invalid video URL without creating a document" do
+ assert_no_difference "MeetingDocument.count" do
+ DownloadTranscriptJob.perform_now(@meeting.id, "https://evil.com/malicious?v=abc")
+ end
+ end
+ end
+end
diff --git a/test/jobs/scrapers/discover_transcripts_job_test.rb b/test/jobs/scrapers/discover_transcripts_job_test.rb
new file mode 100644
index 0000000..3a33b15
--- /dev/null
+++ b/test/jobs/scrapers/discover_transcripts_job_test.rb
@@ -0,0 +1,104 @@
+require "test_helper"
+require "minitest/mock"
+
+class Scrapers::DiscoverTranscriptsJobTest < ActiveJob::TestCase
+ setup do
+ @council_meeting = Meeting.create!(
+ body_name: "City Council Meeting",
+ detail_page_url: "http://example.com/council-apr-6",
+ starts_at: 1.day.ago
+ )
+ @work_session = Meeting.create!(
+ body_name: "City Council Work Session",
+ detail_page_url: "http://example.com/ws-mar-30",
+ starts_at: 47.hours.ago
+ )
+ @plan_commission = Meeting.create!(
+ body_name: "Plan Commission",
+ detail_page_url: "http://example.com/plan-commission",
+ starts_at: 1.day.ago
+ )
+ @old_meeting = Meeting.create!(
+ body_name: "City Council Meeting",
+ detail_page_url: "http://example.com/council-old",
+ starts_at: 5.days.ago
+ )
+ end
+
+ def stub_status(success_bool)
+ status = Minitest::Mock.new
+ status.expect :success?, success_bool
+ status
+ end
+
+ test "parses standard council meeting title and enqueues download" do
+ date_str = @council_meeting.starts_at.strftime("%B %-d, %Y")
+ yt_output = "abc123 | City Council Meeting for Thursday, #{date_str}\n"
+
+ Open3.stub :capture3, [ yt_output, "", stub_status(true) ] do
+ assert_enqueued_with(job: Documents::DownloadTranscriptJob, args: [ @council_meeting.id, "https://www.youtube.com/watch?v=abc123" ]) do
+ Scrapers::DiscoverTranscriptsJob.perform_now
+ end
+ end
+ end
+
+ test "parses work session title and enqueues download" do
+ date_str = @work_session.starts_at.strftime("%B %-d, %Y")
+ yt_output = "def456 | City Council Work Session for Monday, #{date_str}\n"
+
+ Open3.stub :capture3, [ yt_output, "", stub_status(true) ] do
+ assert_enqueued_with(job: Documents::DownloadTranscriptJob, args: [ @work_session.id, "https://www.youtube.com/watch?v=def456" ]) do
+ Scrapers::DiscoverTranscriptsJob.perform_now
+ end
+ end
+ end
+
+ test "skips videos that cannot be parsed" do
+ yt_output = "xyz999 | Some Random Live Stream\n"
+
+ Open3.stub :capture3, [ yt_output, "", stub_status(true) ] do
+ assert_no_enqueued_jobs only: Documents::DownloadTranscriptJob do
+ Scrapers::DiscoverTranscriptsJob.perform_now
+ end
+ end
+ end
+
+ test "skips meetings that already have a transcript" do
+ MeetingDocument.create!(
+ meeting: @council_meeting,
+ document_type: "transcript",
+ source_url: "https://www.youtube.com/watch?v=existing"
+ )
+
+ date_str = @council_meeting.starts_at.strftime("%B %-d, %Y")
+ yt_output = "abc123 | City Council Meeting for Thursday, #{date_str}\n"
+
+ Open3.stub :capture3, [ yt_output, "", stub_status(true) ] do
+ assert_no_enqueued_jobs only: Documents::DownloadTranscriptJob do
+ Scrapers::DiscoverTranscriptsJob.perform_now
+ end
+ end
+ end
+
+ test "skips non-council meetings even if title matches date" do
+ date_str = @plan_commission.starts_at.strftime("%B %-d, %Y")
+ # Plan Commission title won't match TITLE_PATTERN — no job enqueued
+ yt_output = "ghi789 | Plan Commission Meeting for Tuesday, #{date_str}\n"
+
+ Open3.stub :capture3, [ yt_output, "", stub_status(true) ] do
+ assert_no_enqueued_jobs only: Documents::DownloadTranscriptJob do
+ Scrapers::DiscoverTranscriptsJob.perform_now
+ end
+ end
+ end
+
+ test "handles yt-dlp failure gracefully" do
+ Open3.stub :capture3, [ "", "yt-dlp: command not found", stub_status(false) ] do
+ assert_no_enqueued_jobs only: Documents::DownloadTranscriptJob do
+ assert_nothing_raised do
+ Scrapers::DiscoverTranscriptsJob.perform_now
+ end
+ end
+ end
+ end
+end
diff --git a/test/jobs/summarize_meeting_job_test.rb b/test/jobs/summarize_meeting_job_test.rb
index d30c467..5d70e6c 100644
--- a/test/jobs/summarize_meeting_job_test.rb
+++ b/test/jobs/summarize_meeting_job_test.rb
@@ -63,7 +63,8 @@ def retrieval_stub.format_topic_context(*args); []; end
summary = @meeting.meeting_summaries.find_by(summary_type: "minutes_recap")
assert summary, "Should create a minutes_recap summary"
- assert_equal generation_data, summary.generation_data
+ assert_equal "minutes", summary.generation_data["source_type"]
+ assert_equal generation_data["headline"], summary.generation_data["headline"]
assert_nil summary.content
end
@@ -194,6 +195,98 @@ def retrieval_stub.format_topic_context(*args); []; end
mock_ai.verify
end
+ test "generates meeting summary from transcript when no minutes exist" do
+ doc = @meeting.meeting_documents.create!(
+ document_type: "transcript",
+ source_url: "http://example.com/transcript.txt",
+ extracted_text: "Transcript of meeting: The council discussed the budget at length."
+ )
+
+ generation_data = {
+ "headline" => "Council discussed the budget",
+ "highlights" => [
+ { "text" => "Budget discussed", "citation" => "Transcript", "impact" => "medium" }
+ ],
+ "public_input" => [],
+ "item_details" => [],
+ "source_type" => "transcript"
+ }
+
+ mock_ai = Minitest::Mock.new
+ mock_ai.expect :prepare_kb_context, "" do |arg| arg.is_a?(Array) end
+ mock_ai.expect :analyze_meeting_content, generation_data.to_json do |text, kb, type, **kwargs|
+ type == "transcript"
+ end
+ # Topic-level mocks
+ mock_ai.expect :analyze_topic_summary, '{"factual_record": []}' do |arg| arg.is_a?(Hash) end
+ mock_ai.expect :render_topic_summary, "## Summary" do |arg| arg.is_a?(String) end
+
+ retrieval_stub = Object.new
+ def retrieval_stub.retrieve_context(*args, **kwargs); []; end
+ def retrieval_stub.format_context(*args); ""; end
+ def retrieval_stub.retrieve_topic_context(*args, **kwargs); []; end
+ def retrieval_stub.format_topic_context(*args); []; end
+
+ RetrievalService.stub :new, retrieval_stub do
+ Ai::OpenAiService.stub :new, mock_ai do
+ SummarizeMeetingJob.perform_now(@meeting.id)
+ end
+ end
+
+ summary = @meeting.meeting_summaries.find_by(summary_type: "transcript_recap")
+ assert summary, "Should create a transcript_recap summary"
+ assert_equal "transcript", summary.generation_data["source_type"]
+ assert_nil @meeting.meeting_summaries.find_by(summary_type: "minutes_recap")
+ end
+
+ test "minutes take priority over transcript" do
+ @meeting.meeting_documents.create!(
+ document_type: "minutes_pdf",
+ source_url: "http://example.com/minutes.pdf",
+ extracted_text: "Page 1: The council approved the budget 5-2."
+ )
+ @meeting.meeting_documents.create!(
+ document_type: "transcript",
+ source_url: "http://example.com/transcript.txt",
+ extracted_text: "Transcript of meeting: The council discussed the budget."
+ )
+
+ generation_data = {
+ "headline" => "Council approved the budget",
+ "highlights" => [
+ { "text" => "Budget approved", "citation" => "Page 1", "vote" => "5-2", "impact" => "high" }
+ ],
+ "public_input" => [],
+ "item_details" => []
+ }
+
+ mock_ai = Minitest::Mock.new
+ mock_ai.expect :prepare_kb_context, "" do |arg| arg.is_a?(Array) end
+ mock_ai.expect :analyze_meeting_content, generation_data.to_json do |text, kb, type, **kwargs|
+ type == "minutes"
+ end
+ # Topic-level mocks
+ mock_ai.expect :analyze_topic_summary, '{"factual_record": []}' do |arg| arg.is_a?(Hash) end
+ mock_ai.expect :render_topic_summary, "## Summary" do |arg| arg.is_a?(String) end
+
+ retrieval_stub = Object.new
+ def retrieval_stub.retrieve_context(*args, **kwargs); []; end
+ def retrieval_stub.format_context(*args); ""; end
+ def retrieval_stub.retrieve_topic_context(*args, **kwargs); []; end
+ def retrieval_stub.format_topic_context(*args); []; end
+
+ RetrievalService.stub :new, retrieval_stub do
+ Ai::OpenAiService.stub :new, mock_ai do
+ SummarizeMeetingJob.perform_now(@meeting.id)
+ end
+ end
+
+ summary = @meeting.meeting_summaries.find_by(summary_type: "minutes_recap")
+ assert summary, "Should create minutes_recap"
+ assert_equal "minutes_with_transcript", summary.generation_data["source_type"]
+ assert_nil @meeting.meeting_summaries.find_by(summary_type: "transcript_recap"), "Should NOT create transcript_recap"
+ end
+
test "enqueues GenerateTopicBriefingJob after topic summary generation" do
mock_ai = Minitest::Mock.new
# Meeting-level: prepare_kb_context called (no docs, so no analyze call)
diff --git a/test/models/meeting_test.rb b/test/models/meeting_test.rb
index 3d2f5b3..88fdce8 100644
--- a/test/models/meeting_test.rb
+++ b/test/models/meeting_test.rb
@@ -50,4 +50,42 @@ class MeetingTest < ActiveSupport::TestCase
assert_equal :none, meeting.document_status
end
+
+ test "document_status returns :transcript when transcript exists but no minutes or packet" do
+ meeting = Meeting.create!(
+ detail_page_url: "http://example.com/transcript-1",
+ starts_at: Time.current
+ )
+ meeting.meeting_documents.create!(
+ document_type: "transcript",
+ source_url: "https://www.youtube.com/watch?v=test123"
+ )
+ assert_equal :transcript, meeting.document_status
+ end
+
+ test "document_status returns :minutes even when transcript exists" do
+ meeting = Meeting.create!(
+ detail_page_url: "http://example.com/transcript-2",
+ starts_at: Time.current
+ )
+ meeting.meeting_documents.create!(document_type: "minutes_pdf")
+ meeting.meeting_documents.create!(
+ document_type: "transcript",
+ source_url: "https://www.youtube.com/watch?v=test123"
+ )
+ assert_equal :minutes, meeting.document_status
+ end
+
+ test "document_status returns :transcript above :agenda" do
+ meeting = Meeting.create!(
+ detail_page_url: "http://example.com/transcript-3",
+ starts_at: Time.current
+ )
+ meeting.meeting_documents.create!(document_type: "agenda_pdf")
+ meeting.meeting_documents.create!(
+ document_type: "transcript",
+ source_url: "https://www.youtube.com/watch?v=test123"
+ )
+ assert_equal :transcript, meeting.document_status
+ end
end