You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
After shipping the PruneHollowAppearancesJob feature (commits 1e38996..ceaedb9) and re-summarizing 38 meetings on production, topic 513 "garbage and recycling service changes" is still surfacing on the homepage with impact=4 and a briefing that shows 7 record entries that are just "appeared on the agenda" text — not the actual substance of what the committee discussed.
Root-cause investigation revealed three distinct problems, none of which the pruning feature was designed to fix:
The topic-level briefing AI is blind to the meeting minutes content. It only sees agenda structure (item titles + prior agenda-level summaries). Specific incidents — fake garbage stickers, resident complaints about sticker purchases, beach garbage concerns relayed in Council Communications — exist in MeetingSummary.generation_data["item_details"] but never flow to GenerateTopicBriefingJob's context. Pattern detection is impossible without the content.
The KB is empty for the sanitation domain. Topic 513 has zero linked KnowledgeSource entries and the RAG retrieval returns zero chunks for its query. Two Rivers' sticker-based pay-as-you-go collection method is atypical for a city of ~10k, and the open-recycling-bin situation is a known friction point — but none of this civic framing exists in the KB, so the briefing AI has no way to recognize "friction with the unusual sticker system" as a pattern even if it could see the content.
PruneHollowAppearancesJob leaves orphaned TopicSummary rows. The job destroys AgendaItemTopic + TopicAppearance but not the per-meeting TopicSummary. On prod, topic 513 has 4 topic_appearances but 7 topic_summaries — 3 orphans for meetings whose appearances were pruned (m112, m80, m132, m146). GenerateTopicBriefingJob#build_briefing_context (app/jobs/topics/generate_topic_briefing_job.rb:30-33) feeds all of a topic's summaries as prior_meeting_analyses, so the briefing AI keeps regenerating stale factual_record entries from pruned meetings. Discovered during investigation of why the user-facing topic page still showed 7 record entries after pruning.
Fixes 1, 2, and 3 are complementary. Alone, each is insufficient. Together they should produce a topic page that reads like: "Two Rivers' unusual sticker-based collection method has generated multiple friction points this year: fake stickers found in collection (Aug 2025), a resident forced to buy unrelated items to obtain stickers (Jan 2026), garbage concerns at the beach relayed via Council Communications (Jul 2025). The committee has declined to revisit the method, citing cost."
The pruning feature added an activity_level field (decision | discussion | status_update) to the analyze_meeting_content prompt and a new PruneHollowAppearancesJob that runs at the end of SummarizeMeetingJob. It detects "hollow" agenda items (no motion, activity_level="status_update", null vote/decision/public_hearing) and destroys the corresponding AgendaItemTopic + TopicAppearance rows. Topics with ≥1 remaining appearances after pruning get a GenerateTopicBriefingJob re-run to re-rate impact; topics at 0 remaining are blocked + dormant.
Follow-up issue #92 covers smaller review findings from the feature (transaction tweaks, normalize_title edge cases, etc.) — not the three root causes described here.
After deploying the feature, we re-ran SummarizeMeetingJob for 38 meetings covering 17 concerning topics. Topic 513 went from 8 appearances down to 4. But the homepage and topic page still look wrong, which is what triggered this investigation.
Observed prod state (topic 513)
Topic 513: "garbage and recycling service changes"
status: approved
lifecycle: active
impact: 4 (still — the re-rate didn't lower it)
last_activity_at: 2026-04-06 17:00:00 (most recent m178, which has no minutes)
appearances: 4 (was 8 before pruning)
topic_summaries: 7 ← MISMATCH with appearances. 3 are orphans.
briefing.factual_record entries: 7 ← AI regenerates these from orphans + live summaries
The 4 live appearances:
m94 2025-08-04 PUC "SOLID WASTE UTILITY: UPDATES AND ACTION, AS NEEDED" — re-summarized, classified as discussion (committee discussed fake stickers + social media chatter, declined to revisit)
m8 2026-01-05 PUC "SOLID WASTE UTILITY: UPDATES AND ACTION, AS NEEDED" — re-summarized, classified as discussion (resident complaint + "will look into it")
m178 2026-04-06 PUC "Garbage & Recycling Discussion" — no minutes yet, can't be pruned
m178 2026-04-06 PUC "SOLID WASTE UTILITY: UPDATES AND ACTION, AS NEEDED" — no minutes yet, can't be pruned
The 4 pruned appearances (m112, m80, m132, m146) correctly came out because their re-summarized content was classified as status_update (e.g., "preparation of the WDNR grant report is underway", "leaf collection totals"). The prune job worked as designed for those.
Actual minutes content under "SOLID WASTE UTILITY" for each meeting (for reference)
m94 2025-08-04 PUC — committee noted fake garbage stickers being found by Manitowoc Disposal, who is notifying DPW/police. Also noted social media chatter about moving away from the sticker method; committee "did not see a reason to revisit the process."
m8 2026-01-05 PUC — single bullet: "Engineering was contacted on January 5, 2026, by a resident who reported that when attempting to purchase garbage stickers at a local establishment, they were told they needed to purchase an additional item from the business. Engineering and other staff will look into this matter further."
m132 2026-02-02 PUC — single bullet: "Leaf Collection Totals: Director Heckenlaible did not have the final figures available but noted that the quantity of leaves collected exceeded that of previous years."
m146 2026-03-02 PUC — single bullet: "Preparation of the WDNR Solid Waste and Recycling Grant Report is currently underway."
m112 2025-07-07 City Council — topic 513 was linked to "COUNCIL COMMUNICATIONS" on this meeting (not a Solid Waste item). Council member Wachowski relayed several citizen grievances in a round-robin, one of which was "garbage and maintenance issues at the beach" (one sentence, no discussion, no action). Should not count as substantive topic-513 activity.
m178 2026-04-06 PUC — no minutes yet. Agenda has two items linked to topic 513: a standing "SOLID WASTE UTILITY" slot and a "Garbage & Recycling Discussion" item. Can't be evaluated until the city publishes minutes.
current_state: "Two Rivers has repeatedly put 'solid waste utility' updates — and a
separate 'Garbage & Recycling Discussion' — on meeting agendas. The
paper trail here shows the topic staying active across 2025-2026, but
not what changes were chosen."
pattern_observations: [
"The topic recurs across multiple Public Utilities Committee meetings over several
months, indicating ongoing work rather than a one-meeting decision.",
"The topic appears in both City Council and Public Utilities Committee agendas,
consistent with a utility/service issue being handled in committee and at council."
]
factual_record (7 entries, all some variant of):
"Public Utilities Committee agenda included this topic."
"City Council agenda listed 'Solid Waste Utility: Updates and Action, as Needed.'"
etc.
None of the factual_record entries reference the actual content — not the fake stickers, not the resident complaint, not the beach concerns. The AI literally cannot report them because its input doesn't include them.
Root Cause 1: Context starvation at the briefing level
The code path
GenerateTopicBriefingJob#build_briefing_context (app/jobs/topics/generate_topic_briefing_job.rb:29-73) assembles the context fed to analyze_topic_briefing. It pulls:
Then SummaryContextBuilder#agenda_items_data (app/services/topics/summary_context_builder.rb:34-88) resolves each agenda item and returns:
{id: item.id,number: item.number,title: item.title,summary: item.summary,# the agenda-item-level summary field, usually emptyrecommended_action: item.recommended_action,# also usually emptycitation: item_citation,attachments: doc_attachments# item-level packet attachments — usually empty for standing-slot items}
What's missing
The meeting's MeetingSummary.generation_data["item_details"] contains per-item summaries that were generated from the minutes PDF text. For m94's "SOLID WASTE UTILITY" item this is:
"Staff reported ongoing problems with fake garbage stickers showing up on collected refuse, with Manitowoc Disposal notifying DPW and police. The committee also addressed social media calls to move away from the sticker method of collection, noting the 2024 budget process found stickers remained the most affordable option for residents."
That content is written, stored, and shown on the meeting page when rendering item_details — but none of it flows to the topic briefing. The briefing sees only item.title ("SOLID WASTE UTILITY: UPDATES AND ACTION, AS NEEDED") and item.summary (empty).
Why this matters
Pattern detection is impossible without content. The briefing AI can only report "this topic appears on agendas repeatedly" because that's all it sees. The user's question that drove this investigation was exactly this: "we have 4-7 instances of this with dates that we can refer to their minutes. Why doesn't this ever amount to discussion items, votes, etc.? If it's a recurring agenda item with nothing of value actually happening, then stop logging those times in the record."
The real answer is almost the opposite of what the user assumed: the individual instances DO have content (fake stickers, resident complaints, social media chatter, beach concerns, grant paperwork) — but the briefing architecture has no access to any of it, so the topic page can't surface patterns even when they exist.
The fix
In GenerateTopicBriefingJob#build_briefing_context, add a new context key recent_item_details that pulls filtered item_details entries from each recent meeting's MeetingSummary. Filter to only items that have an AgendaItemTopic link to the current topic. No new AI calls, no new model relationships — just plumbing.
Rough shape:
defbuild_briefing_context(topic,meeting,retrieval_service)# ... existing prior_summaries, recent_meeting_ids, recent_meetings ...recent_item_details=recent_meetings.flat_mapdo |m|
summary=m.meeting_summaries.order(created_at: :desc).firstnext[]unlesssummary&.generation_data.is_a?(Hash)details=summary.generation_data["item_details"] || []next[]unlessdetails.is_a?(Array)# Find which agenda items on this meeting are linked to our topiclinked_item_titles=m.agenda_items.joins(:agenda_item_topics).where(agenda_item_topics: {topic_id: topic.id}).pluck(:title).map{ |t| normalize_title_for_match(t)}details.selectdo |entry|
nextfalseunlessentry.is_a?(Hash)e_title=entry["agenda_item_title"]nextfalseunlesse_title.is_a?(String)linked_item_titles.include?(normalize_title_for_match(e_title))end.mapdo |entry|
{meeting_date: m.starts_at&.to_date&.to_s,meeting_body: m.body_name,agenda_item_title: entry["agenda_item_title"],summary: entry["summary"],activity_level: entry["activity_level"],vote: entry["vote"],decision: entry["decision"],public_hearing: entry["public_hearing"]}endend{# ... existing keys ...recent_item_details: recent_item_details,# ... existing keys ...}end
The normalize_title_for_match helper should be the same logic as PruneHollowAppearancesJob#normalize_title (app/jobs/prune_hollow_appearances_job.rb:86-95) — strip leading numbering, trailing "AS NEEDED"/"IF APPLICABLE", squish, downcase. Consider extracting to a shared helper/concern to avoid duplication.
Prompt update
analyze_topic_briefing (key: "analyze_topic_briefing" in lib/prompt_template_data.rb) needs to be told about the new recent_item_details field. Minimum change: add one bullet to the guidelines telling the AI that recent_item_details contains the per-item substantive content from recent meetings, and that it should use this content when writing factual_record entries and detecting patterns.
The existing prompt already emphasizes pattern detection. No need to rewrite it — just add the new data source.
After the prompt update, run bin/rails prompt_templates:populate locally and in prod to sync the DB template.
Tests
Unit test for SummaryContextBuilder (or the new helper method) that verifies recent_item_details is populated correctly when a meeting has a summary with matching item_details.
Unit test that verifies recent_item_details is filtered to only items linked to the topic (not ALL item_details from the meeting).
Unit test that recent_item_details is empty when a meeting has no summary or no matching items.
Integration-style test: stub Ai::OpenAiService.new on GenerateTopicBriefingJob.perform, verify the context passed to analyze_topic_briefing includes recent_item_details with expected shape.
Deployment
Code change + tests, commit, push, deploy.
Run bin/kamal app exec "bin/rails prompt_templates:populate" to sync the prompt template.
Re-run GenerateTopicBriefingJob.perform_later(topic_id: 513, meeting_id: <some m178 id>) on prod.
Verify topic 513's briefing's factual_record now references specific incidents (fake stickers, resident complaint) rather than "appeared on the agenda".
Root Cause 2: Empty KB for the sanitation domain
Current state
Total KnowledgeSources: 205
Total KnowledgeChunks: 284
KnowledgeSources linked to topic 513: (none)
retrieve_topic_context("garbage and recycling service changes"): 0 chunks
The KB has 205 entries covering general civic structure (city manager, debt cap, Comprehensive Plan, concession renovations, beach parking) but nothing specific to sanitation. Topic 513 has zero directly-linked sources, and the RAG retrieval returns zero chunks for its query.
Missing context the KB should provide
Collection method framing: Two Rivers uses a sticker-based pay-as-you-go trash collection method (residents buy stickers at local businesses and affix them to bags). This is atypical for a city of ~10k residents — most cities this size use flat-fee bag/cart systems. The method persists because it's been repeatedly evaluated as the most affordable option during budget cycles (confirmed in m94 minutes: "That was discussed during the 2024 budget preparation process and the garbage sticker option remains to be the most affordable option for the residents of Two Rivers.").
Known friction points: The sticker method generates a recurring thread of resident concerns — fraud (fake stickers appearing in collection, reported by Manitowoc Disposal), purchase-requirement complaints (residents being told they must buy additional items to obtain stickers), and periodic social-media chatter about replacing it.
Recycling bins and lake winds: Two Rivers is a Lake Michigan lakeshore city. Open recycling bins are prone to litter dispersal in windy conditions — a known friction point that affects beach cleanliness.
Committee disposition: The Public Utilities Committee has declined multiple times to revisit the collection method, citing budget constraints.
Why KB entries matter
The briefing AI's query is built from the topic's canonical name + aliases:
With proper KB entries about sticker collection and windy-city recycling, this query would return meaningful context. Combined with the item_details plumbing from RC1, the briefing AI can connect "resident complained about sticker purchase requirement" to "the sticker system is an atypical, repeatedly-debated civic arrangement" and produce a frame that makes the pattern legible.
The fix
Add 3-5 KnowledgeSource entries covering the context above. These are editorial assertions about Two Rivers civic arrangements — they need a human author (not a draft-from-code approach). Options for authoring:
Admin UI: /admin/knowledge_sources supports creating entries directly. This is the normal admin workflow.
Rake task or seeds: less preferred because the content needs verification and editorial judgment.
Hybrid: LLM drafts initial content from the minutes we already have (e.g., the m94 text about stickers remaining the most affordable option can become a source), human verifies before saving.
Suggested entries to create:
Title: "Two Rivers uses sticker-based pay-as-you-go trash collection"
Body: Residents purchase stickers at local businesses and affix them to
bags for collection. This is unusual for a city of ~10k residents;
most Wisconsin cities this size use flat-fee bag or cart systems.
The method has been repeatedly evaluated during budget cycles and
retained on cost grounds — most recently confirmed during the 2024
budget preparation process as the most affordable option for
residents (per Public Utilities Committee minutes, August 2025).
source_type: civic_context
Title: "Sticker method has generated recurring friction with residents"
Body: Known friction points include: (a) fake stickers appearing on
collected refuse, flagged by Manitowoc Disposal and TR Police
(August 2025); (b) resident complaints about local businesses
requiring additional purchases to obtain stickers (January 2026);
(c) social-media chatter periodically calling for a switch to
automated collection. The Public Utilities Committee has declined
each time to revisit the method.
source_type: civic_context
Title: "Open recycling bins are prone to wind-driven litter"
Body: Two Rivers is a Lake Michigan lakeshore city with significant
prevailing winds. Open recycling bins — particularly at public
spaces and the lakefront — are a recurring source of litter
dispersal. Resident complaints about beach garbage often trace
back to this design limitation.
source_type: civic_context
Link each to topic 513 (and potentially related topics) via KnowledgeSourceTopic. The relevance_score should be high (0.8-1.0) since these are directly about the topic's domain.
Verification
After creating the entries, running retrieve_topic_context for topic 513 should return non-zero chunks, and a new briefing generation should cite them.
Scope
RC2 is editorial work, not code work. It can be done in parallel with RC1 and RC3 without blocking or being blocked by them. The briefing won't benefit from new KB entries until RC1's plumbing fix lands (because right now the briefing DOES use KB but it's empty) — but the entries themselves can be authored at any time.
Root Cause 3: Orphan TopicSummary rows
The bug
PruneHollowAppearancesJob#perform (app/jobs/prune_hollow_appearances_job.rb:25-59) destroys AgendaItemTopic + TopicAppearance rows for hollow items, but does not destroy the corresponding TopicSummary rows (per-meeting topic digests created by SummarizeMeetingJob#generate_topic_summaries at app/jobs/summarize_meeting_job.rb:83-130).
On prod, this leaves topic 513 with:
topic_appearances: 4
topic_summaries: 7 (3 orphans for pruned meetings)
The orphans are for m112, m80, m132, m146 (the meetings whose appearances were correctly pruned by the feature).
Why this causes visible user impact
GenerateTopicBriefingJob#build_briefing_context pulls ALL of a topic's summaries:
These are fed to the briefing AI as prior_meeting_analyses. The AI then synthesizes factual_record entries from them. Since the orphans are still in place, the briefing's factual_record has 7 entries (including some that reference pruned meetings with stale descriptions). The user sees "7 items" in the record — including one that looks like a duplicate:
[0] 2025-07-07 City Council — "agenda included an item labeled 'Council Communications'"
[1] 2025-07-07 City Council — "agenda listed 'Solid Waste Utility: Updates and Action, as Needed'"
The second entry is suspect — m112 was a City Council meeting whose agenda had Council Communications, not Solid Waste Utility. The AI is conflating cross-referenced content from orphaned topic_summaries (ts489 m80 2025-09-02 PUC's factual_record references m112 2025-07-07 City Council with a hallucinated "solid waste utility" description).
The fix
Extend PruneHollowAppearancesJob#perform so that after destroying a hollow item's appearances on a meeting, it also destroys TopicSummary.where(topic_id:, meeting_id:)if and only if the topic has no remaining TopicAppearance on that meeting.
Critical constraint: a topic can have multiple appearances on the same meeting through different agenda items (m178 has 2 for topic 513). If ONE appearance is pruned but another remains, the TopicSummary must be preserved.
Sketch (to insert between the destroy loop and the demotion loop in perform):
This goes AFTER the agenda_items.each destroy loop but BEFORE the affected_topic_ids.each do |topic_id|; topic = Topic.find_by...; demote_topic(topic, meeting_id) demotion loop — so demote_topic still sees the correct topic_appearances.count.
Tests
Add two tests to test/jobs/prune_hollow_appearances_job_test.rb:
"destroys orphaned TopicSummary when all of a topic's appearances on a meeting are pruned" — creates a single hollow appearance + a TopicSummary for the (topic, meeting) pair; runs the job; asserts the TopicSummary is destroyed.
"preserves TopicSummary when topic still has other appearances on the same meeting" — creates two appearances on the same meeting (one hollow, one substantive), a TopicSummary for the pair; runs the job; asserts the hollow appearance is pruned but the TopicSummary survives because the substantive appearance still exists.
See the test helpers already in place: create_meeting_with_item, create_summary, link_topic.
One-time cleanup on prod
After the code fix deploys, there will still be orphan TopicSummary rows left over from the prior pruning run (topic 513 has 3-4, other topics may have more). A one-time cleanup:
# On prod via `bin/kamal console` or `bin/kamal app exec "bin/rails runner ..."`orphans=TopicSummary.joins("LEFT JOIN topic_appearances ta ON ta.topic_id = topic_summaries.topic_id AND ta.meeting_id = topic_summaries.meeting_id").where("ta.id IS NULL")puts"Orphan TopicSummary count: #{orphans.count}"orphans.find_each(&:destroy!)# Then re-run briefing for affected topicsaffected_topic_ids=orphans.pluck(:topic_id).uniq# (capture this before the destroy, or group by topic_id first)affected_topic_ids.eachdo |tid|
topic=Topic.find(tid)# Pick any meeting where the topic still has an appearancemeeting_id=topic.topic_appearances.first&.meeting_idnextunlessmeeting_idTopics::GenerateTopicBriefingJob.perform_later(topic_id: tid,meeting_id: meeting_id)end
Better: capture orphan topic IDs into a set BEFORE the destroy loop, then iterate after.
Recommended order of operations
RC3 first (orphan TopicSummary fix): smallest code change, unblocks correct behavior for subsequent steps. Code + tests + commit + deploy + prod cleanup query. Estimated 1-2 hours including verification.
RC1 next (item_details plumbing): larger code change, touches GenerateTopicBriefingJob, SummaryContextBuilder, and the analyze_topic_briefing prompt. Tests required. Code + tests + commit + deploy + prompt populate. Estimated half a day.
RC2 in parallel with anything (KB entries): editorial work, can be done via admin UI at any point. Does not block code work but should be in place before re-running topic 513's briefing to validate the full-stack fix.
Final verification: after all three are done, re-run GenerateTopicBriefingJob for topic 513 once. Expected result: factual_record entries reference real incidents (fake stickers, resident complaint, beach concerns), editorial_analysis.current_state frames them as the unusual-sticker-system pattern, and resident_impact_score gets re-rated (likely drops below 4 since most appearances are genuinely minor friction, not service changes).
Out of scope
Do NOT split topic 513 via topics:split_broad_topic["garbage and recycling service changes"]. The topic name is fine as a container once the KB provides the framing and the briefing can see the content. Splitting would lose the "recurring friction with the sticker system" pattern, which is the actual civic story.
Do NOT iterate the activity_level prompt further. The AI's classifications for m94 and m8 are defensible under the current "when in doubt, choose discussion" rule. The problem isn't that too much is being classified as discussion — the problem is that the topic-level briefing can't see the content to distinguish substantive discussion from "staff will look into it". Fixing RC1 addresses this without touching the classifier.
Do NOT try to detect patterns in the backend (analytics/stats). The pattern-detection mechanism is the briefing AI. Its output is already structured to support pattern narratives (editorial_analysis.current_state, pattern_observations, continuity_signals). We just need to feed it the content (RC1) + the frame (RC2) + stop polluting it with stale data (RC3).
On prod: topic 513's topic_summaries.count == topic_appearances.distinct.count(:meeting_id) (no orphans)
On prod: topic 513 has at least 3 linked KnowledgeSource entries via KnowledgeSourceTopic
On prod: rs.retrieve_topic_context(topic: t, query_text: query, limit: 5, max_chars: 6000) returns 3+ chunks for topic 513
On prod: topic 513's briefing factual_record references specific incidents (fake stickers / resident complaint / beach concerns), not just "appeared on agenda"
On prod: topic 513's resident_impact_score is either appropriately re-rated by the AI or confirmed to still be 4 for editorial reasons
On prod: topic 513 no longer appears in Top Stories on https://tworiversmatters.com (or does, for the right reasons)
TL;DR
After shipping the
PruneHollowAppearancesJobfeature (commits1e38996..ceaedb9) and re-summarizing 38 meetings on production, topic 513 "garbage and recycling service changes" is still surfacing on the homepage with impact=4 and a briefing that shows 7 record entries that are just "appeared on the agenda" text — not the actual substance of what the committee discussed.Root-cause investigation revealed three distinct problems, none of which the pruning feature was designed to fix:
MeetingSummary.generation_data["item_details"]but never flow toGenerateTopicBriefingJob's context. Pattern detection is impossible without the content.KnowledgeSourceentries and the RAG retrieval returns zero chunks for its query. Two Rivers' sticker-based pay-as-you-go collection method is atypical for a city of ~10k, and the open-recycling-bin situation is a known friction point — but none of this civic framing exists in the KB, so the briefing AI has no way to recognize "friction with the unusual sticker system" as a pattern even if it could see the content.PruneHollowAppearancesJobleaves orphanedTopicSummaryrows. The job destroysAgendaItemTopic+TopicAppearancebut not the per-meetingTopicSummary. On prod, topic 513 has 4 topic_appearances but 7 topic_summaries — 3 orphans for meetings whose appearances were pruned (m112, m80, m132, m146).GenerateTopicBriefingJob#build_briefing_context(app/jobs/topics/generate_topic_briefing_job.rb:30-33) feeds all of a topic's summaries asprior_meeting_analyses, so the briefing AI keeps regenerating stalefactual_recordentries from pruned meetings. Discovered during investigation of why the user-facing topic page still showed 7 record entries after pruning.Fixes 1, 2, and 3 are complementary. Alone, each is insufficient. Together they should produce a topic page that reads like: "Two Rivers' unusual sticker-based collection method has generated multiple friction points this year: fake stickers found in collection (Aug 2025), a resident forced to buy unrelated items to obtain stickers (Jan 2026), garbage concerns at the beach relayed via Council Communications (Jul 2025). The committee has declined to revisit the method, citing cost."
What we already built (don't redo this)
Commits on master
1e38996..ceaedb9. Design spec:docs/superpowers/specs/2026-04-11-prune-hollow-topic-appearances-design.md. Implementation plan:docs/superpowers/plans/2026-04-11-prune-hollow-topic-appearances.md.The pruning feature added an
activity_levelfield (decision | discussion | status_update) to theanalyze_meeting_contentprompt and a newPruneHollowAppearancesJobthat runs at the end ofSummarizeMeetingJob. It detects "hollow" agenda items (no motion,activity_level="status_update", null vote/decision/public_hearing) and destroys the correspondingAgendaItemTopic+TopicAppearancerows. Topics with ≥1 remaining appearances after pruning get aGenerateTopicBriefingJobre-run to re-rate impact; topics at 0 remaining are blocked + dormant.Follow-up issue #92 covers smaller review findings from the feature (transaction tweaks,
normalize_titleedge cases, etc.) — not the three root causes described here.After deploying the feature, we re-ran
SummarizeMeetingJobfor 38 meetings covering 17 concerning topics. Topic 513 went from 8 appearances down to 4. But the homepage and topic page still look wrong, which is what triggered this investigation.Observed prod state (topic 513)
The 4 live appearances:
discussion(committee discussed fake stickers + social media chatter, declined to revisit)discussion(resident complaint + "will look into it")The 4 pruned appearances (m112, m80, m132, m146) correctly came out because their re-summarized content was classified as
status_update(e.g., "preparation of the WDNR grant report is underway", "leaf collection totals"). The prune job worked as designed for those.Actual minutes content under "SOLID WASTE UTILITY" for each meeting (for reference)
m94 2025-08-04 PUC — committee noted fake garbage stickers being found by Manitowoc Disposal, who is notifying DPW/police. Also noted social media chatter about moving away from the sticker method; committee "did not see a reason to revisit the process."
m80 2025-09-02 PUC — preliminary 2026 budget discussion items (haven't looked formally yet, Manitowoc rate increases, fuel/landfill costs up, consider modification of collection bins, leaf collection labor-intensive). Director-monologue list, no committee response.
m8 2026-01-05 PUC — single bullet: "Engineering was contacted on January 5, 2026, by a resident who reported that when attempting to purchase garbage stickers at a local establishment, they were told they needed to purchase an additional item from the business. Engineering and other staff will look into this matter further."
m132 2026-02-02 PUC — single bullet: "Leaf Collection Totals: Director Heckenlaible did not have the final figures available but noted that the quantity of leaves collected exceeded that of previous years."
m146 2026-03-02 PUC — single bullet: "Preparation of the WDNR Solid Waste and Recycling Grant Report is currently underway."
m112 2025-07-07 City Council — topic 513 was linked to "COUNCIL COMMUNICATIONS" on this meeting (not a Solid Waste item). Council member Wachowski relayed several citizen grievances in a round-robin, one of which was "garbage and maintenance issues at the beach" (one sentence, no discussion, no action). Should not count as substantive topic-513 activity.
m178 2026-04-06 PUC — no minutes yet. Agenda has two items linked to topic 513: a standing "SOLID WASTE UTILITY" slot and a "Garbage & Recycling Discussion" item. Can't be evaluated until the city publishes minutes.
Briefing editorial_analysis (current, post-pruning)
None of the factual_record entries reference the actual content — not the fake stickers, not the resident complaint, not the beach concerns. The AI literally cannot report them because its input doesn't include them.
Root Cause 1: Context starvation at the briefing level
The code path
GenerateTopicBriefingJob#build_briefing_context(app/jobs/topics/generate_topic_briefing_job.rb:29-73) assembles the context fed toanalyze_topic_briefing. It pulls:Then
SummaryContextBuilder#agenda_items_data(app/services/topics/summary_context_builder.rb:34-88) resolves each agenda item and returns:What's missing
The meeting's
MeetingSummary.generation_data["item_details"]contains per-item summaries that were generated from the minutes PDF text. For m94's "SOLID WASTE UTILITY" item this is:That content is written, stored, and shown on the meeting page when rendering
item_details— but none of it flows to the topic briefing. The briefing sees onlyitem.title("SOLID WASTE UTILITY: UPDATES AND ACTION, AS NEEDED") anditem.summary(empty).Why this matters
Pattern detection is impossible without content. The briefing AI can only report "this topic appears on agendas repeatedly" because that's all it sees. The user's question that drove this investigation was exactly this: "we have 4-7 instances of this with dates that we can refer to their minutes. Why doesn't this ever amount to discussion items, votes, etc.? If it's a recurring agenda item with nothing of value actually happening, then stop logging those times in the record."
The real answer is almost the opposite of what the user assumed: the individual instances DO have content (fake stickers, resident complaints, social media chatter, beach concerns, grant paperwork) — but the briefing architecture has no access to any of it, so the topic page can't surface patterns even when they exist.
The fix
In
GenerateTopicBriefingJob#build_briefing_context, add a new context keyrecent_item_detailsthat pulls filtereditem_detailsentries from each recent meeting'sMeetingSummary. Filter to only items that have anAgendaItemTopiclink to the current topic. No new AI calls, no new model relationships — just plumbing.Rough shape:
The
normalize_title_for_matchhelper should be the same logic asPruneHollowAppearancesJob#normalize_title(app/jobs/prune_hollow_appearances_job.rb:86-95) — strip leading numbering, trailing "AS NEEDED"/"IF APPLICABLE", squish, downcase. Consider extracting to a shared helper/concern to avoid duplication.Prompt update
analyze_topic_briefing(key:"analyze_topic_briefing"inlib/prompt_template_data.rb) needs to be told about the newrecent_item_detailsfield. Minimum change: add one bullet to the guidelines telling the AI thatrecent_item_detailscontains the per-item substantive content from recent meetings, and that it should use this content when writingfactual_recordentries and detecting patterns.The existing prompt already emphasizes pattern detection. No need to rewrite it — just add the new data source.
After the prompt update, run
bin/rails prompt_templates:populatelocally and in prod to sync the DB template.Tests
SummaryContextBuilder(or the new helper method) that verifiesrecent_item_detailsis populated correctly when a meeting has a summary with matching item_details.recent_item_detailsis filtered to only items linked to the topic (not ALL item_details from the meeting).recent_item_detailsis empty when a meeting has no summary or no matching items.Ai::OpenAiService.newonGenerateTopicBriefingJob.perform, verify the context passed toanalyze_topic_briefingincludesrecent_item_detailswith expected shape.Deployment
bin/kamal app exec "bin/rails prompt_templates:populate"to sync the prompt template.GenerateTopicBriefingJob.perform_later(topic_id: 513, meeting_id: <some m178 id>)on prod.factual_recordnow references specific incidents (fake stickers, resident complaint) rather than "appeared on the agenda".Root Cause 2: Empty KB for the sanitation domain
Current state
The KB has 205 entries covering general civic structure (city manager, debt cap, Comprehensive Plan, concession renovations, beach parking) but nothing specific to sanitation. Topic 513 has zero directly-linked sources, and the RAG retrieval returns zero chunks for its query.
Missing context the KB should provide
Why KB entries matter
The briefing AI's query is built from the topic's canonical name + aliases:
With proper KB entries about sticker collection and windy-city recycling, this query would return meaningful context. Combined with the item_details plumbing from RC1, the briefing AI can connect "resident complained about sticker purchase requirement" to "the sticker system is an atypical, repeatedly-debated civic arrangement" and produce a frame that makes the pattern legible.
The fix
Add 3-5
KnowledgeSourceentries covering the context above. These are editorial assertions about Two Rivers civic arrangements — they need a human author (not a draft-from-code approach). Options for authoring:/admin/knowledge_sourcessupports creating entries directly. This is the normal admin workflow.Suggested entries to create:
Link each to topic 513 (and potentially related topics) via
KnowledgeSourceTopic. Therelevance_scoreshould be high (0.8-1.0) since these are directly about the topic's domain.Verification
After creating the entries, running
retrieve_topic_contextfor topic 513 should return non-zero chunks, and a new briefing generation should cite them.Scope
RC2 is editorial work, not code work. It can be done in parallel with RC1 and RC3 without blocking or being blocked by them. The briefing won't benefit from new KB entries until RC1's plumbing fix lands (because right now the briefing DOES use KB but it's empty) — but the entries themselves can be authored at any time.
Root Cause 3: Orphan
TopicSummaryrowsThe bug
PruneHollowAppearancesJob#perform(app/jobs/prune_hollow_appearances_job.rb:25-59) destroysAgendaItemTopic+TopicAppearancerows for hollow items, but does not destroy the correspondingTopicSummaryrows (per-meeting topic digests created bySummarizeMeetingJob#generate_topic_summariesatapp/jobs/summarize_meeting_job.rb:83-130).On prod, this leaves topic 513 with:
The orphans are for m112, m80, m132, m146 (the meetings whose appearances were correctly pruned by the feature).
Why this causes visible user impact
GenerateTopicBriefingJob#build_briefing_contextpulls ALL of a topic's summaries:These are fed to the briefing AI as
prior_meeting_analyses. The AI then synthesizesfactual_recordentries from them. Since the orphans are still in place, the briefing'sfactual_recordhas 7 entries (including some that reference pruned meetings with stale descriptions). The user sees "7 items" in the record — including one that looks like a duplicate:The second entry is suspect — m112 was a City Council meeting whose agenda had Council Communications, not Solid Waste Utility. The AI is conflating cross-referenced content from orphaned topic_summaries (ts489 m80 2025-09-02 PUC's factual_record references m112 2025-07-07 City Council with a hallucinated "solid waste utility" description).
The fix
Extend
PruneHollowAppearancesJob#performso that after destroying a hollow item's appearances on a meeting, it also destroysTopicSummary.where(topic_id:, meeting_id:)if and only if the topic has no remainingTopicAppearanceon that meeting.Critical constraint: a topic can have multiple appearances on the same meeting through different agenda items (m178 has 2 for topic 513). If ONE appearance is pruned but another remains, the
TopicSummarymust be preserved.Sketch (to insert between the destroy loop and the demotion loop in
perform):This goes AFTER the
agenda_items.eachdestroy loop but BEFORE theaffected_topic_ids.each do |topic_id|; topic = Topic.find_by...; demote_topic(topic, meeting_id)demotion loop — sodemote_topicstill sees the correcttopic_appearances.count.Tests
Add two tests to
test/jobs/prune_hollow_appearances_job_test.rb:"destroys orphaned TopicSummary when all of a topic's appearances on a meeting are pruned"— creates a single hollow appearance + aTopicSummaryfor the (topic, meeting) pair; runs the job; asserts theTopicSummaryis destroyed."preserves TopicSummary when topic still has other appearances on the same meeting"— creates two appearances on the same meeting (one hollow, one substantive), aTopicSummaryfor the pair; runs the job; asserts the hollow appearance is pruned but theTopicSummarysurvives because the substantive appearance still exists.See the test helpers already in place:
create_meeting_with_item,create_summary,link_topic.One-time cleanup on prod
After the code fix deploys, there will still be orphan
TopicSummaryrows left over from the prior pruning run (topic 513 has 3-4, other topics may have more). A one-time cleanup:Better: capture orphan topic IDs into a set BEFORE the destroy loop, then iterate after.
Recommended order of operations
GenerateTopicBriefingJob,SummaryContextBuilder, and theanalyze_topic_briefingprompt. Tests required. Code + tests + commit + deploy + prompt populate. Estimated half a day.GenerateTopicBriefingJobfor topic 513 once. Expected result:factual_recordentries reference real incidents (fake stickers, resident complaint, beach concerns),editorial_analysis.current_stateframes them as the unusual-sticker-system pattern, andresident_impact_scoregets re-rated (likely drops below 4 since most appearances are genuinely minor friction, not service changes).Out of scope
topics:split_broad_topic["garbage and recycling service changes"]. The topic name is fine as a container once the KB provides the framing and the briefing can see the content. Splitting would lose the "recurring friction with the sticker system" pattern, which is the actual civic story.activity_levelprompt further. The AI's classifications for m94 and m8 are defensible under the current "when in doubt, choose discussion" rule. The problem isn't that too much is being classified asdiscussion— the problem is that the topic-level briefing can't see the content to distinguish substantive discussion from "staff will look into it". Fixing RC1 addresses this without touching the classifier.editorial_analysis.current_state,pattern_observations,continuity_signals). We just need to feed it the content (RC1) + the frame (RC2) + stop polluting it with stale data (RC3).References
docs/superpowers/specs/2026-04-11-prune-hollow-topic-appearances-design.mddocs/superpowers/plans/2026-04-11-prune-hollow-topic-appearances.md1e38996..ceaedb9(the pruning feature + hotfix)app/jobs/prune_hollow_appearances_job.rb(RC3 fix target)app/jobs/topics/generate_topic_briefing_job.rb:29-73(RC1 fix target —build_briefing_context)app/services/topics/summary_context_builder.rb:34-88(RC1 fix target —agenda_items_data)app/jobs/summarize_meeting_job.rb:83-130(read-only reference — howTopicSummaryrows get created)app/services/ai/open_ai_service.rb(prompt-template fetching foranalyze_topic_briefing)lib/prompt_template_data.rb(source of truth for prompt text;analyze_topic_briefingentry needs to mentionrecent_item_details)lib/tasks/prompt_templates.rake(populatetask for syncing DB)Verification checklist (for when the fixes are complete)
bin/rails test test/jobs/prune_hollow_appearances_job_test.rb— all tests pass, including 2 new tests for orphan cleanupGenerateTopicBriefingJob/SummaryContextBuildercoveringrecent_item_detailsplumbingbin/rubocop— cleanbin/rails prompt_templates:populate— confirmsanalyze_topic_briefingupdatedtopic_summaries.count == topic_appearances.distinct.count(:meeting_id)(no orphans)KnowledgeSourceentries viaKnowledgeSourceTopicrs.retrieve_topic_context(topic: t, query_text: query, limit: 5, max_chars: 6000)returns 3+ chunks for topic 513factual_recordreferences specific incidents (fake stickers / resident complaint / beach concerns), not just "appeared on agenda"resident_impact_scoreis either appropriately re-rated by the AI or confirmed to still be 4 for editorial reasons