{"id":371,"date":"2026-10-09T09:53:47","date_gmt":"2026-10-09T17:53:47","guid":{"rendered":"https:\/\/rainier-it.com\/blog\/?p=371"},"modified":"2026-10-21T00:06:40","modified_gmt":"2026-10-21T07:06:40","slug":"orting-news-self-hosted-local-news-pipeline","status":"publish","type":"post","link":"https:\/\/rainier-it.com\/blog\/orting-news-self-hosted-local-news-pipeline\/","title":{"rendered":"Orting News: How We Built a Self-Hosted Local News Feed (Polling, Whisper and a Human in the Loop)"},"content":{"rendered":"\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><em>The City Council met for almost four hours on a Wednesday night. They raised utility rates, allowed homes in downtown commercial buildings and approved a water-main deal with the state. The next morning, almost nobody in town knew.<\/em><\/p>\n<\/blockquote>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p class=\"wp-block-paragraph\">Orting doesn&#8217;t have a newspaper. Like a lot of small towns, the news is all public, but it&#8217;s scattered: city notices in one place, the fire department&#8217;s posts in another, school board meetings on YouTube and four-hour council recordings that nobody has time to watch.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So we added <strong>Orting News<\/strong> to <a href=\"https:\/\/shoporting.org\" target=\"_blank\" rel=\"noopener\">Shop Orting<\/a>, our free community site: <a href=\"https:\/\/shoporting.org\/news\/\" target=\"_blank\" rel=\"noopener\">shoporting.org\/news<\/a>. It collects public records from the city, the county, the school district, the fire department and the Weather Service, and turns them into short stories in plain English. Every story links to its source. Council and school board meetings come with an AI summary <em>and<\/em> the full transcript, so you can check the summary against what was actually said.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This post is the tour: how the polling works, how a four-hour meeting becomes a readable story, the guardrails we put on the AI, and how we keep an eye on all of it.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"1440\" height=\"1560\" src=\"https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-page.jpg\" alt=\"The Orting News page: a headline, category filters and a grid of story cards with photos\" class=\"wp-image-365\" srcset=\"https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-page.jpg 1440w, https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-page-277x300.jpg 277w, https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-page-945x1024.jpg 945w, https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-page-768x832.jpg 768w, https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-page-1418x1536.jpg 1418w\" sizes=\"auto, (max-width: 1440px) 100vw, 1440px\" \/><figcaption class=\"wp-element-caption\">shoporting.org\/news. Every card is a public record turned into a short story, with its source on the card.<\/figcaption><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\ud83e\udded The pipeline at a glance<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The whole thing is a small Python worker plus a WordPress site. Once an hour, the worker checks each source, turns anything new into a story and saves it as a draft. A person reviews every draft from a Discord message before it goes live.<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span style=\"display:block;padding:16px 0 0 16px;margin-bottom:-1px;width:100%;text-align:left;background-color:#2e3440ff\"><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"54\" height=\"14\" viewBox=\"0 0 54 14\"><g fill=\"none\" fill-rule=\"evenodd\" transform=\"translate(1 1)\"><circle cx=\"6\" cy=\"6\" r=\"6\" fill=\"#FF5F56\" stroke=\"#E0443E\" stroke-width=\".5\"><\/circle><circle cx=\"26\" cy=\"6\" r=\"6\" fill=\"#FFBD2E\" stroke=\"#DEA123\" stroke-width=\".5\"><\/circle><circle cx=\"46\" cy=\"6\" r=\"6\" fill=\"#27C93F\" stroke=\"#1AAB29\" stroke-width=\".5\"><\/circle><\/g><\/svg><\/span><span role=\"button\" tabindex=\"0\" style=\"color:#d8dee9ff;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly>public sources \u2500\u25ba poll (hourly) \u2500\u25ba rules \u2500\u25ba dedupe \u2500\u25ba image \u2500\u2510\n                                                             \u25bc\nnew council or school board meeting                        draft\n  \u2514\u2500\u25ba GPU: Whisper \u2500\u25ba who's talking \u2500\u25ba AI summary \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25ba\u2502\n                                                             \u25bc\n                                Discord: Publish \/ Edit \/ Reject\n                                                             \u25bc\n                                             shoporting.org\/news<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki nord\" style=\"background-color:#2e3440ff;color:#d8dee9ff\" tabindex=\"0\"><code><span class=\"line\"><span>public sources \u2500\u25ba poll (hourly) \u2500\u25ba rules \u2500\u25ba dedupe \u2500\u25ba image \u2500\u2510<\/span><\/span>\n<span class=\"line\"><span>                                                             \u25bc<\/span><\/span>\n<span class=\"line\"><span>new council or school board meeting                        draft<\/span><\/span>\n<span class=\"line\"><span>  \u2514\u2500\u25ba GPU: Whisper \u2500\u25ba who's talking \u2500\u25ba AI summary \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25ba\u2502<\/span><\/span>\n<span class=\"line\"><span>                                                             \u25bc<\/span><\/span>\n<span class=\"line\"><span>                                Discord: Publish \/ Edit \/ Reject<\/span><\/span>\n<span class=\"line\"><span>                                                             \u25bc<\/span><\/span>\n<span class=\"line\"><span>                                             shoporting.org\/news<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">A few principles shaped everything else:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Public records only.<\/strong> Every story starts from something a public agency published, and every story links back to it.<\/li>\n\n\n\n<li><strong>Plain English, no spin.<\/strong> Stories say what happened. Meeting summaries never say a measure &#8220;passed unanimously&#8221; unless someone in the meeting said so.<\/li>\n\n\n\n<li><strong>A person approves everything.<\/strong> The AI drafts; it never publishes.<\/li>\n\n\n\n<li><strong>Self-hosted.<\/strong> Transcription runs on our own GPU, not a paid cloud API, so a four-hour meeting costs us a few minutes of electricity.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\ud83d\udce1 Polling: once an hour, politely<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The sources are ordinary public feeds: the City of Orting&#8217;s RSS feed, the state&#8217;s environmental review (SEPA) register, Central Pierce Fire &amp; Rescue and Pierce County news, the school district&#8217;s announcements and YouTube channel, National Weather Service alerts for Orting, and the county TV station&#8217;s council meeting archive.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">We started with one run a day, at 5pm. But a notice posted at 9am shouldn&#8217;t wait eight hours to show up, so we moved to hourly. The worker runs from a systemd timer, not WordPress&#8217;s built-in cron, because WordPress cron only fires when someone visits the site:<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span style=\"display:block;padding:16px 0 0 16px;margin-bottom:-1px;width:100%;text-align:left;background-color:#2e3440ff\"><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"54\" height=\"14\" viewBox=\"0 0 54 14\"><g fill=\"none\" fill-rule=\"evenodd\" transform=\"translate(1 1)\"><circle cx=\"6\" cy=\"6\" r=\"6\" fill=\"#FF5F56\" stroke=\"#E0443E\" stroke-width=\".5\"><\/circle><circle cx=\"26\" cy=\"6\" r=\"6\" fill=\"#FFBD2E\" stroke=\"#DEA123\" stroke-width=\".5\"><\/circle><circle cx=\"46\" cy=\"6\" r=\"6\" fill=\"#27C93F\" stroke=\"#1AAB29\" stroke-width=\".5\"><\/circle><\/g><\/svg><\/span><span role=\"button\" tabindex=\"0\" style=\"color:#d8dee9ff;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly># \/etc\/systemd\/system\/so-news.timer\n&#91;Unit&#93;\nDescription=Run Orting News hourly\n\n&#91;Timer&#93;\nOnCalendar=hourly\nPersistent=true\n\n&#91;Install&#93;\nWantedBy=timers.target<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki nord\" style=\"background-color: #2e3440ff\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #616E88\"># \/etc\/systemd\/system\/so-news.timer<\/span><\/span>\n<span class=\"line\"><span style=\"color: #ECEFF4\">&#91;<\/span><span style=\"color: #D8DEE9FF\">Unit<\/span><span style=\"color: #ECEFF4\">&#93;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #81A1C1\">Description<\/span><span style=\"color: #ECEFF4\">=<\/span><span style=\"color: #D8DEE9FF\">Run Orting News hourly<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #ECEFF4\">&#91;<\/span><span style=\"color: #D8DEE9FF\">Timer<\/span><span style=\"color: #ECEFF4\">&#93;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #81A1C1\">OnCalendar<\/span><span style=\"color: #ECEFF4\">=<\/span><span style=\"color: #D8DEE9FF\">hourly<\/span><\/span>\n<span class=\"line\"><span style=\"color: #81A1C1\">Persistent<\/span><span style=\"color: #ECEFF4\">=<\/span><span style=\"color: #D8DEE9FF\">true<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #ECEFF4\">&#91;<\/span><span style=\"color: #D8DEE9FF\">Install<\/span><span style=\"color: #ECEFF4\">&#93;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #81A1C1\">WantedBy<\/span><span style=\"color: #ECEFF4\">=<\/span><span style=\"color: #D8DEE9FF\">timers.target<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span style=\"display:block;padding:16px 0 0 16px;margin-bottom:-1px;width:100%;text-align:left;background-color:#2e3440ff\"><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"54\" height=\"14\" viewBox=\"0 0 54 14\"><g fill=\"none\" fill-rule=\"evenodd\" transform=\"translate(1 1)\"><circle cx=\"6\" cy=\"6\" r=\"6\" fill=\"#FF5F56\" stroke=\"#E0443E\" stroke-width=\".5\"><\/circle><circle cx=\"26\" cy=\"6\" r=\"6\" fill=\"#FFBD2E\" stroke=\"#DEA123\" stroke-width=\".5\"><\/circle><circle cx=\"46\" cy=\"6\" r=\"6\" fill=\"#27C93F\" stroke=\"#1AAB29\" stroke-width=\".5\"><\/circle><\/g><\/svg><\/span><span role=\"button\" tabindex=\"0\" style=\"color:#d8dee9ff;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly># \/etc\/systemd\/system\/so-news.service\n&#91;Unit&#93;\nDescription=Orting News hourly run\nAfter=network-online.target mariadb.service\n\n&#91;Service&#93;\nType=oneshot\nEnvironmentFile=\/etc\/so-news.env\nTimeoutStartSec=4h\nExecStart=\/opt\/so-news-venv\/bin\/python \/usr\/local\/lib\/so-news\/so_news.py<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki nord\" style=\"background-color: #2e3440ff\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #616E88\"># \/etc\/systemd\/system\/so-news.service<\/span><\/span>\n<span class=\"line\"><span style=\"color: #ECEFF4\">&#91;<\/span><span style=\"color: #D8DEE9FF\">Unit<\/span><span style=\"color: #ECEFF4\">&#93;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #81A1C1\">Description<\/span><span style=\"color: #ECEFF4\">=<\/span><span style=\"color: #D8DEE9FF\">Orting News hourly run<\/span><\/span>\n<span class=\"line\"><span style=\"color: #81A1C1\">After<\/span><span style=\"color: #ECEFF4\">=<\/span><span style=\"color: #D8DEE9FF\">network-online.target mariadb.service<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #ECEFF4\">&#91;<\/span><span style=\"color: #D8DEE9FF\">Service<\/span><span style=\"color: #ECEFF4\">&#93;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #81A1C1\">Type<\/span><span style=\"color: #ECEFF4\">=<\/span><span style=\"color: #D8DEE9FF\">oneshot<\/span><\/span>\n<span class=\"line\"><span style=\"color: #81A1C1\">EnvironmentFile<\/span><span style=\"color: #ECEFF4\">=<\/span><span style=\"color: #D8DEE9FF\">\/etc\/so-news.env<\/span><\/span>\n<span class=\"line\"><span style=\"color: #81A1C1\">TimeoutStartSec<\/span><span style=\"color: #ECEFF4\">=<\/span><span style=\"color: #D8DEE9FF\">4h<\/span><\/span>\n<span class=\"line\"><span style=\"color: #81A1C1\">ExecStart<\/span><span style=\"color: #ECEFF4\">=<\/span><span style=\"color: #D8DEE9FF\">\/opt\/so-news-venv\/bin\/python \/usr\/local\/lib\/so-news\/so_news.py<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\"><code>Type=oneshot<\/code> with a long timeout matters: a meeting transcription can take a while, and systemd won&#8217;t start the next hourly run while the last one is still going, so runs never pile up.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Going from daily to hourly changed two things beyond the schedule:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Quiet when there&#8217;s nothing to say.<\/strong> The daily run always posted a &#8220;0 drafts, no errors&#8221; summary to Discord. Hourly, that&#8217;s 24 pings a day of nothing. Now a run only speaks up when it finds a story or hits an error.<\/li>\n\n\n\n<li><strong>Retries spread out.<\/strong> A meeting gets three tries at transcription. Hourly, a GPU server that&#8217;s down for one evening would have used all three in three hours. Now a failed meeting waits at least six hours before the next try.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">We&#8217;re also careful about <em>how<\/em> we poll. The worker identifies itself with a User-Agent that names the site and a contact email, makes one request per source per run, and never retries around a 403, a rate limit or a captcha. When a site blocks automated requests, we ask the owner for access instead of trying to get around the block.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\ud83e\uddf9 Rules, dedupe and the duplicate we shipped anyway<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Each source has a small parser that turns its feed into the same shape: an ID, a title, a date, a link and maybe an image. Simple rules then decide the category (council, schools, safety, development\u2026) and write the story text. Parsers and rules are pure functions, tested against saved copies of real feeds, so a change to one source can&#8217;t quietly break another. The worker has 76 automated tests.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Dedupe works by <strong>source ID<\/strong>: every story stores where it came from (<code>youtube:&lt;video id&gt;<\/code>, <code>sepa:&lt;number&gt;<\/code>, and so on), and the worker skips anything it has already seen. A normalized-title check catches the same notice posted twice under different IDs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That still let one through. When we launched, we imported the first month of stories with a one-off script, and that script never saved source IDs. The next morning, the pipeline found a school district video we&#8217;d already imported, couldn&#8217;t match it, and drafted it again. It never went live, because a person caught it in review. We backfilled the missing IDs. The lesson: <strong>any path that creates content has to write the same dedupe keys as the main path<\/strong>, including the &#8220;just this once&#8221; scripts.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\ud83c\udf99\ufe0f From a four-hour meeting to a readable story<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Council meetings are where the real decisions happen, and they&#8217;re the hardest part. Here&#8217;s what happens when a new recording appears:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Transcribe on our own GPU.<\/strong> The audio goes to a server in our rack with an NVIDIA GTX 1660 Super, running Whisper (large-v3-turbo). It transcribes about 15 times faster than real time. Our first try at a full four-hour meeting ran out of memory, so the job now cuts the audio into 30-minute chunks and stitches the timestamps back together.<\/li>\n\n\n\n<li><strong>Work out who&#8217;s talking.<\/strong> A voice model groups the speech into speakers. Then we put names to them, using the meeting&#8217;s own cues: the mayor introduces themself, and when the chair says &#8220;Council Member Reed?&#8221;, the next new voice is probably Council Member Reed. Voices we&#8217;ve identified before are remembered for next time. Anyone we can&#8217;t identify stays &#8220;Speaker 3&#8221;, and short replies like &#8220;Aye&#8221; are left unlabeled.<\/li>\n\n\n\n<li><strong>Summarize with an AI model.<\/strong> A language model reads the labeled transcript and writes a short summary: the decisions, what was only discussed, and the topics of public comment.<\/li>\n\n\n\n<li><strong>Save it as a draft<\/strong> with the summary on top, the full transcript below and a chapter list to jump to each agenda item.<\/li>\n<\/ol>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"1440\" height=\"1690\" src=\"https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-meeting-summary.jpg\" alt=\"A council meeting story: the headline, an AI summary box with decisions and vote counts, and the start of the full transcript\" class=\"wp-image-366\" srcset=\"https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-meeting-summary.jpg 1440w, https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-meeting-summary-256x300.jpg 256w, https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-meeting-summary-873x1024.jpg 873w, https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-meeting-summary-768x901.jpg 768w, https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-meeting-summary-1309x1536.jpg 1309w\" sizes=\"auto, (max-width: 1440px) 100vw, 1440px\" \/><figcaption class=\"wp-element-caption\">A council story. The AI summary is labeled as AI and sits above the full transcript.<\/figcaption><\/figure>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"840\" height=\"820\" src=\"https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-transcript.jpg\" alt=\"The full transcript: a list of agenda chapters with timestamps, then the transcript with speaker names\" class=\"wp-image-367\" srcset=\"https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-transcript.jpg 840w, https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-transcript-300x293.jpg 300w, https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-transcript-768x750.jpg 768w\" sizes=\"auto, (max-width: 840px) 100vw, 840px\" \/><figcaption class=\"wp-element-caption\">Below every summary: agenda chapters you can jump to, then the transcript with speakers named.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">One speaker-naming lesson: early on, a single stray &#8220;Council Member\u2026&#8221; cue put the name of a council member who was excused that night on someone else&#8217;s voice. Now a lone cue only counts for a speaker who talks a lot; quiet speakers need more evidence or stay unnamed. A wrong name is worse than no name.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\ud83d\udee1\ufe0f Guardrails on the AI<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A summary of a public meeting has to be right, and language models are confident even when they&#8217;re wrong. So the instructions are strict, and the code double-checks the result instead of trusting it:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Only facts from the transcript.<\/strong> Every dollar figure and percentage in the summary must actually appear in the transcript. If one doesn&#8217;t, the draft is flagged.<\/li>\n\n\n\n<li><strong>Discussed is not decided.<\/strong> The headline can only say &#8220;approves&#8221; or &#8220;passes&#8221; for something that was voted on. If the summary lists the item under &#8220;discussed, no vote yet&#8221;, the draft is flagged.<\/li>\n\n\n\n<li><strong>No invented votes.<\/strong> If the clerk didn&#8217;t read out a count, it&#8217;s &#8220;passed on a voice vote&#8221;. It&#8217;s never &#8220;unanimous&#8221; unless someone in the meeting said &#8220;unanimous&#8221;.<\/li>\n\n\n\n<li><strong>No private residents&#8217; names.<\/strong> Officials can be named. People who speak during public comment are described by topic, not by name.<\/li>\n<\/ul>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span style=\"display:block;padding:16px 0 0 16px;margin-bottom:-1px;width:100%;text-align:left;background-color:#2e3440ff\"><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"54\" height=\"14\" viewBox=\"0 0 54 14\"><g fill=\"none\" fill-rule=\"evenodd\" transform=\"translate(1 1)\"><circle cx=\"6\" cy=\"6\" r=\"6\" fill=\"#FF5F56\" stroke=\"#E0443E\" stroke-width=\".5\"><\/circle><circle cx=\"26\" cy=\"6\" r=\"6\" fill=\"#FFBD2E\" stroke=\"#DEA123\" stroke-width=\".5\"><\/circle><circle cx=\"46\" cy=\"6\" r=\"6\" fill=\"#27C93F\" stroke=\"#1AAB29\" stroke-width=\".5\"><\/circle><\/g><\/svg><\/span><span role=\"button\" tabindex=\"0\" style=\"color:#d8dee9ff;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly># After the AI writes the summary, check it against the transcript.\nhave = _norm(' '.join(seg&#91;2&#93; for seg in segments))     # everything that was said\nfigures = _figures(html.unescape(summary))              # $ amounts and percentages\nflags = &#91;f'Figure not found in transcript: {f}'\n         for f in sorted(figures) if _norm(f) not in have&#93;\nif re.search(r'\\bunanimous', summary, re.I) and 'unanimous' not in have:\n    flags.append('Says \"unanimous\", but nobody said that in the meeting')<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki nord\" style=\"background-color: #2e3440ff\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #616E88\"># After the AI writes the summary, check it against the transcript.<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">have <\/span><span style=\"color: #81A1C1\">=<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #88C0D0\">_norm<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #A3BE8C\"> <\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #ECEFF4\">.<\/span><span style=\"color: #88C0D0\">join<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #D8DEE9FF\">seg<\/span><span style=\"color: #ECEFF4\">&#91;<\/span><span style=\"color: #B48EAD\">2<\/span><span style=\"color: #ECEFF4\">&#93;<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">for<\/span><span style=\"color: #D8DEE9FF\"> seg <\/span><span style=\"color: #81A1C1\">in<\/span><span style=\"color: #D8DEE9FF\"> segments<\/span><span style=\"color: #ECEFF4\">))<\/span><span style=\"color: #D8DEE9FF\">     <\/span><span style=\"color: #616E88\"># everything that was said<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">figures <\/span><span style=\"color: #81A1C1\">=<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #88C0D0\">_figures<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #D8DEE9FF\">html<\/span><span style=\"color: #ECEFF4\">.<\/span><span style=\"color: #88C0D0\">unescape<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #D8DEE9FF\">summary<\/span><span style=\"color: #ECEFF4\">))<\/span><span style=\"color: #D8DEE9FF\">              <\/span><span style=\"color: #616E88\"># $ amounts and percentages<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">flags <\/span><span style=\"color: #81A1C1\">=<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">&#91;<\/span><span style=\"color: #81A1C1\">f<\/span><span style=\"color: #A3BE8C\">&#39;Figure not found in transcript: <\/span><span style=\"color: #EBCB8B\">{<\/span><span style=\"color: #D8DEE9FF\">f<\/span><span style=\"color: #EBCB8B\">}<\/span><span style=\"color: #A3BE8C\">&#39;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">         <\/span><span style=\"color: #81A1C1\">for<\/span><span style=\"color: #D8DEE9FF\"> f <\/span><span style=\"color: #81A1C1\">in<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #88C0D0\">sorted<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #D8DEE9FF\">figures<\/span><span style=\"color: #ECEFF4\">)<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">if<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #88C0D0\">_norm<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #D8DEE9FF\">f<\/span><span style=\"color: #ECEFF4\">)<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">not<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">in<\/span><span style=\"color: #D8DEE9FF\"> have<\/span><span style=\"color: #ECEFF4\">&#93;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #81A1C1\">if<\/span><span style=\"color: #D8DEE9FF\"> re<\/span><span style=\"color: #ECEFF4\">.<\/span><span style=\"color: #88C0D0\">search<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #81A1C1\">r<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #EBCB8B\">\\bunanimous<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #ECEFF4\">,<\/span><span style=\"color: #D8DEE9FF\"> summary<\/span><span style=\"color: #ECEFF4\">,<\/span><span style=\"color: #D8DEE9FF\"> re<\/span><span style=\"color: #ECEFF4\">.<\/span><span style=\"color: #D8DEE9FF\">I<\/span><span style=\"color: #ECEFF4\">)<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">and<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #A3BE8C\">unanimous<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">not<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">in<\/span><span style=\"color: #D8DEE9FF\"> have<\/span><span style=\"color: #ECEFF4\">:<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">    flags<\/span><span style=\"color: #ECEFF4\">.<\/span><span style=\"color: #88C0D0\">append<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #A3BE8C\">Says &quot;unanimous&quot;, but nobody said that in the meeting<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #ECEFF4\">)<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">Flags don&#8217;t block a draft. They show up in the Discord review message, right next to the Publish button, so the reviewer knows exactly what to check.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\u2705 A person approves every story<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Nothing publishes on its own. Each draft arrives in Discord with its headline, image, excerpt, any flags and three links: <strong>Publish<\/strong>, <strong>Edit<\/strong> and <strong>Reject<\/strong>. Publish and Reject work in one click from a phone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One-click links that change a website need care. Each link carries a signature made with a secret only the server knows, and it only works for someone logged in with permission to publish:<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span style=\"display:block;padding:16px 0 0 16px;margin-bottom:-1px;width:100%;text-align:left;background-color:#2e3440ff\"><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"54\" height=\"14\" viewBox=\"0 0 54 14\"><g fill=\"none\" fill-rule=\"evenodd\" transform=\"translate(1 1)\"><circle cx=\"6\" cy=\"6\" r=\"6\" fill=\"#FF5F56\" stroke=\"#E0443E\" stroke-width=\".5\"><\/circle><circle cx=\"26\" cy=\"6\" r=\"6\" fill=\"#FFBD2E\" stroke=\"#DEA123\" stroke-width=\".5\"><\/circle><circle cx=\"46\" cy=\"6\" r=\"6\" fill=\"#27C93F\" stroke=\"#1AAB29\" stroke-width=\".5\"><\/circle><\/g><\/svg><\/span><span role=\"button\" tabindex=\"0\" style=\"color:#d8dee9ff;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly>\/** Signed Publish \/ Reject links for a draft.\n *  Valid only for a logged-in user who can publish. *\/\nfunction so_news_review_links( $id ) {\n\t$sig = fn( $do ) => substr(\n\t\thash_hmac( 'sha256', \"$id|$do\", wp_salt( 'auth' ) ), 0, 32 );\n\t$url = fn( $do ) => add_query_arg( &#91;\n\t\t'action' => 'so_news_review', 'post' => $id,\n\t\t'do'     => $do,              't'    => $sig( $do ),\n\t&#93;, admin_url( 'admin-post.php' ) );\n\treturn &#91;\n\t\t'publish' => $url( 'publish' ),\n\t\t'reject'  => $url( 'reject' ),\n\t\t'edit'    => admin_url( \"post.php?post=$id&amp;action=edit\" ),\n\t&#93;;\n}<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki nord\" style=\"background-color: #2e3440ff\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #616E88\">\/** Signed Publish \/ Reject links for a draft.<\/span><\/span>\n<span class=\"line\"><span style=\"color: #616E88\"> *  Valid only for a logged-in user who can publish. *\/<\/span><\/span>\n<span class=\"line\"><span style=\"color: #81A1C1\">function<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #88C0D0\">so_news_review_links<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">$<\/span><span style=\"color: #D8DEE9\">id<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">)<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">{<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">\t<\/span><span style=\"color: #81A1C1\">$<\/span><span style=\"color: #D8DEE9\">sig<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">=<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">fn<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">$<\/span><span style=\"color: #D8DEE9\">do<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">)<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">=&gt;<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #88C0D0\">substr<\/span><span style=\"color: #ECEFF4\">(<\/span><\/span>\n<span class=\"line\"><span style=\"color: #88C0D0\">\t\thash_hmac<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #A3BE8C\">sha256<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #ECEFF4\">,<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #ECEFF4\">&quot;<\/span><span style=\"color: #81A1C1\">$<\/span><span style=\"color: #D8DEE9\">id<\/span><span style=\"color: #A3BE8C\">|<\/span><span style=\"color: #81A1C1\">$<\/span><span style=\"color: #D8DEE9\">do<\/span><span style=\"color: #ECEFF4\">&quot;<\/span><span style=\"color: #ECEFF4\">,<\/span><span style=\"color: #88C0D0\"> wp_salt<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #A3BE8C\">auth<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #ECEFF4\">)<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #ECEFF4\">),<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #B48EAD\">0<\/span><span style=\"color: #ECEFF4\">,<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #B48EAD\">32<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #ECEFF4\">)<\/span><span style=\"color: #81A1C1\">;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">\t<\/span><span style=\"color: #81A1C1\">$<\/span><span style=\"color: #D8DEE9\">url<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">=<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">fn<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">$<\/span><span style=\"color: #D8DEE9\">do<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">)<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">=&gt;<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #88C0D0\">add_query_arg<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #ECEFF4\">&#91;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #88C0D0\">\t\t<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #A3BE8C\">action<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #81A1C1\">=&gt;<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #A3BE8C\">so_news_review<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #ECEFF4\">,<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #A3BE8C\">post<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #81A1C1\">=&gt;<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #81A1C1\">$<\/span><span style=\"color: #D8DEE9\">id<\/span><span style=\"color: #ECEFF4\">,<\/span><\/span>\n<span class=\"line\"><span style=\"color: #88C0D0\">\t\t<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #A3BE8C\">do<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #88C0D0\">     <\/span><span style=\"color: #81A1C1\">=&gt;<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #81A1C1\">$<\/span><span style=\"color: #D8DEE9\">do<\/span><span style=\"color: #ECEFF4\">,<\/span><span style=\"color: #88C0D0\">              <\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #A3BE8C\">t<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #88C0D0\">    <\/span><span style=\"color: #81A1C1\">=&gt;<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #81A1C1\">$<\/span><span style=\"color: #D8DEE9\">sig<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #81A1C1\">$<\/span><span style=\"color: #D8DEE9\">do<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #ECEFF4\">),<\/span><\/span>\n<span class=\"line\"><span style=\"color: #88C0D0\">\t<\/span><span style=\"color: #ECEFF4\">&#93;,<\/span><span style=\"color: #88C0D0\"> admin_url<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #A3BE8C\">admin-post.php<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #ECEFF4\">)<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #ECEFF4\">)<\/span><span style=\"color: #81A1C1\">;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">\t<\/span><span style=\"color: #81A1C1\">return<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">&#91;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">\t\t<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #A3BE8C\">publish<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">=&gt;<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">$<\/span><span style=\"color: #D8DEE9\">url<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #A3BE8C\">publish<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">),<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">\t\t<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #A3BE8C\">reject<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #D8DEE9FF\">  <\/span><span style=\"color: #81A1C1\">=&gt;<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">$<\/span><span style=\"color: #D8DEE9\">url<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #A3BE8C\">reject<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">),<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">\t\t<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #A3BE8C\">edit<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #D8DEE9FF\">    <\/span><span style=\"color: #81A1C1\">=&gt;<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #88C0D0\">admin_url<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #ECEFF4\">&quot;<\/span><span style=\"color: #A3BE8C\">post.php?post=<\/span><span style=\"color: #81A1C1\">$<\/span><span style=\"color: #D8DEE9\">id<\/span><span style=\"color: #A3BE8C\">&amp;action=edit<\/span><span style=\"color: #ECEFF4\">&quot;<\/span><span style=\"color: #88C0D0\"> <\/span><span style=\"color: #ECEFF4\">),<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">\t<\/span><span style=\"color: #ECEFF4\">&#93;<\/span><span style=\"color: #81A1C1\">;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #ECEFF4\">}<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">If someone forwards the Discord message, the links are useless to them: they&#8217;d still have to log in as an editor, and a link for one story can&#8217;t be changed to act on another.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\ud83d\uddd3\ufe0f Newsroom standards, written in code<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Running a news feed means making the same editorial calls that newsrooms do, and making them every time. We wrote these into the pipeline:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Say when it happened.<\/strong> The school district posts board meeting videos a day or more after the meeting. When a meeting happened before the story&#8217;s date, the card starts with <em>&#8220;From the Thursday, Oct. 8 work session:&#8221;<\/em> and the story says when it was held and when the video came out.<\/li>\n\n\n\n<li><strong>Say when it&#8217;s an update.<\/strong> If a new story covers the same thing as a recent one, it opens with <em>&#8220;Update: this follows our Sept. 30 story&#8221;<\/em>, linked. When it&#8217;s published, the earlier story is marked <em>Updated<\/em> with a link forward.<\/li>\n\n\n\n<li><strong>&#8220;Updated&#8221; is set on purpose.<\/strong> The story page shows <em>Published \u2026 \u00b7 Updated \u2026<\/em> under the headline. Cards don&#8217;t. It&#8217;s never driven by &#8220;last modified&#8221;, so fixing a typo doesn&#8217;t make a story look like news.<\/li>\n\n\n\n<li><strong>Corrections are visible and dated.<\/strong> When we get a fact wrong, the story gets a dated <em>Correction<\/em> note at the end. We don&#8217;t quietly edit the mistake away.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Matching &#8220;is this the same story?&#8221; is deliberately conservative: a new headline has to share most of its key words with a recent one. It misses some loose follow-ups, but it doesn&#8217;t invent connections. And it&#8217;s only a suggestion: the reviewer sees the flag before anything goes live.<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span style=\"display:block;padding:16px 0 0 16px;margin-bottom:-1px;width:100%;text-align:left;background-color:#2e3440ff\"><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"54\" height=\"14\" viewBox=\"0 0 54 14\"><g fill=\"none\" fill-rule=\"evenodd\" transform=\"translate(1 1)\"><circle cx=\"6\" cy=\"6\" r=\"6\" fill=\"#FF5F56\" stroke=\"#E0443E\" stroke-width=\".5\"><\/circle><circle cx=\"26\" cy=\"6\" r=\"6\" fill=\"#FFBD2E\" stroke=\"#DEA123\" stroke-width=\".5\"><\/circle><circle cx=\"46\" cy=\"6\" r=\"6\" fill=\"#27C93F\" stroke=\"#1AAB29\" stroke-width=\".5\"><\/circle><\/g><\/svg><\/span><span role=\"button\" tabindex=\"0\" style=\"color:#d8dee9ff;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly>def mark_update(s, recent):\n    \"\"\"Does a recent story cover the same thing? (most headline words shared)\"\"\"\n    a, best = _words(s&#91;'title'&#93;), None\n    for pid, title, date, url in recent:\n        b = _words(title)\n        shared = len(a &amp; b)\n        # exact duplicates were already skipped by source ID and title\n        same = norm_title(title) == norm_title(s&#91;'title'&#93;)\n        if not same and shared >= 3 and shared \/ max(1, min(len(a), len(b))) >= 0.6:\n            if not best or shared > best&#91;0&#93;:\n                best = (shared, pid, title, date, url)\n    ...<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki nord\" style=\"background-color: #2e3440ff\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #81A1C1\">def<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #88C0D0\">mark_update<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #D8DEE9\">s<\/span><span style=\"color: #ECEFF4\">,<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #D8DEE9\">recent<\/span><span style=\"color: #ECEFF4\">):<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">    <\/span><span style=\"color: #ECEFF4\">&quot;&quot;&quot;<\/span><span style=\"color: #A3BE8C\">Does a recent story cover the same thing? (most headline words shared)<\/span><span style=\"color: #ECEFF4\">&quot;&quot;&quot;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">    a<\/span><span style=\"color: #ECEFF4\">,<\/span><span style=\"color: #D8DEE9FF\"> best <\/span><span style=\"color: #81A1C1\">=<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #88C0D0\">_words<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #D8DEE9FF\">s<\/span><span style=\"color: #ECEFF4\">&#91;<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #A3BE8C\">title<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #ECEFF4\">&#93;),<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">None<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">    <\/span><span style=\"color: #81A1C1\">for<\/span><span style=\"color: #D8DEE9FF\"> pid<\/span><span style=\"color: #ECEFF4\">,<\/span><span style=\"color: #D8DEE9FF\"> title<\/span><span style=\"color: #ECEFF4\">,<\/span><span style=\"color: #D8DEE9FF\"> date<\/span><span style=\"color: #ECEFF4\">,<\/span><span style=\"color: #D8DEE9FF\"> url <\/span><span style=\"color: #81A1C1\">in<\/span><span style=\"color: #D8DEE9FF\"> recent<\/span><span style=\"color: #ECEFF4\">:<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">        b <\/span><span style=\"color: #81A1C1\">=<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #88C0D0\">_words<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #D8DEE9FF\">title<\/span><span style=\"color: #ECEFF4\">)<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">        shared <\/span><span style=\"color: #81A1C1\">=<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #88C0D0\">len<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #D8DEE9FF\">a <\/span><span style=\"color: #81A1C1\">&amp;<\/span><span style=\"color: #D8DEE9FF\"> b<\/span><span style=\"color: #ECEFF4\">)<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">        <\/span><span style=\"color: #616E88\"># exact duplicates were already skipped by source ID and title<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">        same <\/span><span style=\"color: #81A1C1\">=<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #88C0D0\">norm_title<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #D8DEE9FF\">title<\/span><span style=\"color: #ECEFF4\">)<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">==<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #88C0D0\">norm_title<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #D8DEE9FF\">s<\/span><span style=\"color: #ECEFF4\">&#91;<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #A3BE8C\">title<\/span><span style=\"color: #ECEFF4\">&#39;<\/span><span style=\"color: #ECEFF4\">&#93;)<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">        <\/span><span style=\"color: #81A1C1\">if<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">not<\/span><span style=\"color: #D8DEE9FF\"> same <\/span><span style=\"color: #81A1C1\">and<\/span><span style=\"color: #D8DEE9FF\"> shared <\/span><span style=\"color: #81A1C1\">&gt;=<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #B48EAD\">3<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">and<\/span><span style=\"color: #D8DEE9FF\"> shared <\/span><span style=\"color: #81A1C1\">\/<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #88C0D0\">max<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #B48EAD\">1<\/span><span style=\"color: #ECEFF4\">,<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #88C0D0\">min<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #88C0D0\">len<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #D8DEE9FF\">a<\/span><span style=\"color: #ECEFF4\">),<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #88C0D0\">len<\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #D8DEE9FF\">b<\/span><span style=\"color: #ECEFF4\">)))<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">&gt;=<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #B48EAD\">0.6<\/span><span style=\"color: #ECEFF4\">:<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">            <\/span><span style=\"color: #81A1C1\">if<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">not<\/span><span style=\"color: #D8DEE9FF\"> best <\/span><span style=\"color: #81A1C1\">or<\/span><span style=\"color: #D8DEE9FF\"> shared <\/span><span style=\"color: #81A1C1\">&gt;<\/span><span style=\"color: #D8DEE9FF\"> best<\/span><span style=\"color: #ECEFF4\">&#91;<\/span><span style=\"color: #B48EAD\">0<\/span><span style=\"color: #ECEFF4\">&#93;:<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">                best <\/span><span style=\"color: #81A1C1\">=<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">(<\/span><span style=\"color: #D8DEE9FF\">shared<\/span><span style=\"color: #ECEFF4\">,<\/span><span style=\"color: #D8DEE9FF\"> pid<\/span><span style=\"color: #ECEFF4\">,<\/span><span style=\"color: #D8DEE9FF\"> title<\/span><span style=\"color: #ECEFF4\">,<\/span><span style=\"color: #D8DEE9FF\"> date<\/span><span style=\"color: #ECEFF4\">,<\/span><span style=\"color: #D8DEE9FF\"> url<\/span><span style=\"color: #ECEFF4\">)<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">    ...<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\ud83c\udf0a The data that can&#8217;t go stale<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Orting sits between two rivers and below Mount Rainier, so the site&#8217;s &#8220;Be ready&#8221; page carries live hazard data: Puyallup and Carbon river gauges from NOAA, Mount Rainier&#8217;s alert level from USGS, Weather Service warnings and nearby earthquakes. Those feeds refresh every 15 minutes, separately from the news worker.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"1440\" height=\"850\" src=\"https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-river-graphic.jpg\" alt=\"The Puyallup River graphic: current flow against the NWS flood bands, the record and December 2025 crests, and a 7-day flow chart with the forecast\" class=\"wp-image-368\" srcset=\"https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-river-graphic.jpg 1440w, https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-river-graphic-300x177.jpg 300w, https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-river-graphic-1024x604.jpg 1024w, https:\/\/rainier-it.com\/blog\/wp-content\/uploads\/2026\/10\/orting-news-river-graphic-768x453.jpg 768w\" sizes=\"auto, (max-width: 1440px) 100vw, 1440px\" \/><figcaption class=\"wp-element-caption\">The river graphic hides itself if the data is more than six hours old, rather than show an old number as current.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Stale safety data is worse than none, so the graphic <strong>hides itself<\/strong> when the gauge data is more than six hours old. That raises the monitoring question: a page can load perfectly and still be missing the one thing people came for.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\ud83d\udcc8 Watching the watchers<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Shop Orting is now a public resource, so it gets the same monitoring as our client systems, in layers:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Is the site up?<\/strong> Prometheus&#8217;s blackbox exporter checks the home page, \/news and \/be-ready every minute, along with the TLS certificate. A failure for five minutes opens a ticket in our helpdesk and sends an email. Uptime Kuma checks the same pages independently and posts to Discord.<\/li>\n\n\n\n<li><strong>Is the data there?<\/strong> A content check loads \/be-ready and fails unless the live river reading is on the page.<\/li>\n\n\n\n<li><strong>Is the server healthy?<\/strong> Disk, memory and CPU, plus an SSH check.<\/li>\n\n\n\n<li><strong>Are the jobs running?<\/strong> Alerts fire if any of the site&#8217;s jobs fails, or if the hourly news timer hasn&#8217;t fired in three hours.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The content check is the one we like best, because it catches the failure that &#8220;is the site up?&#8221; can&#8217;t:<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span style=\"display:block;padding:16px 0 0 16px;margin-bottom:-1px;width:100%;text-align:left;background-color:#2e3440ff\"><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"54\" height=\"14\" viewBox=\"0 0 54 14\"><g fill=\"none\" fill-rule=\"evenodd\" transform=\"translate(1 1)\"><circle cx=\"6\" cy=\"6\" r=\"6\" fill=\"#FF5F56\" stroke=\"#E0443E\" stroke-width=\".5\"><\/circle><circle cx=\"26\" cy=\"6\" r=\"6\" fill=\"#FFBD2E\" stroke=\"#DEA123\" stroke-width=\".5\"><\/circle><circle cx=\"46\" cy=\"6\" r=\"6\" fill=\"#27C93F\" stroke=\"#1AAB29\" stroke-width=\".5\"><\/circle><\/g><\/svg><\/span><span role=\"button\" tabindex=\"0\" style=\"color:#d8dee9ff;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly># blackbox.yml: the page must contain the live river reading\nhttp_shoporting_river:\n  prober: http\n  timeout: 10s\n  http:\n    valid_status_codes: []   # 2xx\n    fail_if_body_not_matches_regexp: &#91;\"cfs&lt;\/b> flowing past Orting\"&#93;<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki nord\" style=\"background-color: #2e3440ff\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #616E88\"># blackbox.yml: the page must contain the live river reading<\/span><\/span>\n<span class=\"line\"><span style=\"color: #8FBCBB\">http_shoporting_river<\/span><span style=\"color: #ECEFF4\">:<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">  <\/span><span style=\"color: #8FBCBB\">prober<\/span><span style=\"color: #ECEFF4\">:<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #A3BE8C\">http<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">  <\/span><span style=\"color: #8FBCBB\">timeout<\/span><span style=\"color: #ECEFF4\">:<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #A3BE8C\">10s<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">  <\/span><span style=\"color: #8FBCBB\">http<\/span><span style=\"color: #ECEFF4\">:<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">    <\/span><span style=\"color: #8FBCBB\">valid_status_codes<\/span><span style=\"color: #ECEFF4\">:<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">[]<\/span><span style=\"color: #D8DEE9FF\">   <\/span><span style=\"color: #616E88\"># 2xx<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">    <\/span><span style=\"color: #8FBCBB\">fail_if_body_not_matches_regexp<\/span><span style=\"color: #ECEFF4\">:<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">&#91;<\/span><span style=\"color: #ECEFF4\">&quot;<\/span><span style=\"color: #A3BE8C\">cfs&lt;\/b&gt; flowing past Orting<\/span><span style=\"color: #ECEFF4\">&quot;<\/span><span style=\"color: #ECEFF4\">&#93;<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span style=\"display:block;padding:16px 0 0 16px;margin-bottom:-1px;width:100%;text-align:left;background-color:#2e3440ff\"><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"54\" height=\"14\" viewBox=\"0 0 54 14\"><g fill=\"none\" fill-rule=\"evenodd\" transform=\"translate(1 1)\"><circle cx=\"6\" cy=\"6\" r=\"6\" fill=\"#FF5F56\" stroke=\"#E0443E\" stroke-width=\".5\"><\/circle><circle cx=\"26\" cy=\"6\" r=\"6\" fill=\"#FFBD2E\" stroke=\"#DEA123\" stroke-width=\".5\"><\/circle><circle cx=\"46\" cy=\"6\" r=\"6\" fill=\"#27C93F\" stroke=\"#1AAB29\" stroke-width=\".5\"><\/circle><\/g><\/svg><\/span><span role=\"button\" tabindex=\"0\" style=\"color:#d8dee9ff;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly># Alert rules\n- alert: ShopOrtingRiverDataStale\n  expr: probe_success{job=\"blackbox-content\"} == 0\n  for: 15m\n  labels: { severity: critical }\n  annotations:\n    summary: \"shoporting.org\/be-ready is not showing live Puyallup river data\"\n\n- alert: ShopOrtingTimerStalled\n  expr: >-\n    time() - node_systemd_timer_last_trigger_seconds{\n      instance=\"shoporting\", name=\"so-news.timer\"} > 3 * 3600\n  for: 10m\n  labels: { severity: warning }<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki nord\" style=\"background-color: #2e3440ff\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #616E88\"># Alert rules<\/span><\/span>\n<span class=\"line\"><span style=\"color: #ECEFF4\">-<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #8FBCBB\">alert<\/span><span style=\"color: #ECEFF4\">:<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #A3BE8C\">ShopOrtingRiverDataStale<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">  <\/span><span style=\"color: #8FBCBB\">expr<\/span><span style=\"color: #ECEFF4\">:<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #A3BE8C\">probe_success{job=&quot;blackbox-content&quot;} == 0<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">  <\/span><span style=\"color: #8FBCBB\">for<\/span><span style=\"color: #ECEFF4\">:<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #A3BE8C\">15m<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">  <\/span><span style=\"color: #8FBCBB\">labels<\/span><span style=\"color: #ECEFF4\">:<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">{<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #8FBCBB\">severity<\/span><span style=\"color: #ECEFF4\">:<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #A3BE8C\">critical<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">}<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">  <\/span><span style=\"color: #8FBCBB\">annotations<\/span><span style=\"color: #ECEFF4\">:<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">    <\/span><span style=\"color: #8FBCBB\">summary<\/span><span style=\"color: #ECEFF4\">:<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">&quot;<\/span><span style=\"color: #A3BE8C\">shoporting.org\/be-ready is not showing live Puyallup river data<\/span><span style=\"color: #ECEFF4\">&quot;<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #ECEFF4\">-<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #8FBCBB\">alert<\/span><span style=\"color: #ECEFF4\">:<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #A3BE8C\">ShopOrtingTimerStalled<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">  <\/span><span style=\"color: #8FBCBB\">expr<\/span><span style=\"color: #ECEFF4\">:<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #81A1C1\">&gt;-<\/span><\/span>\n<span class=\"line\"><span style=\"color: #A3BE8C\">    time() - node_systemd_timer_last_trigger_seconds{<\/span><\/span>\n<span class=\"line\"><span style=\"color: #A3BE8C\">      instance=&quot;shoporting&quot;, name=&quot;so-news.timer&quot;} &gt; 3 * 3600<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">  <\/span><span style=\"color: #8FBCBB\">for<\/span><span style=\"color: #ECEFF4\">:<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #A3BE8C\">10m<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D8DEE9FF\">  <\/span><span style=\"color: #8FBCBB\">labels<\/span><span style=\"color: #ECEFF4\">:<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">{<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #8FBCBB\">severity<\/span><span style=\"color: #ECEFF4\">:<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #A3BE8C\">warning<\/span><span style=\"color: #D8DEE9FF\"> <\/span><span style=\"color: #ECEFF4\">}<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">The timer check uses node_exporter&#8217;s systemd collector, which reports when each timer last fired. A news run only lasts a few seconds, so checking whether the service is &#8220;running&#8221; would almost never catch it. &#8220;When did the timer last fire?&#8221; is the question that actually tells you something is stuck.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\ud83e\udde0 What we learned<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Write the same keys from every path.<\/strong> Our only duplicate came from a one-off import script that skipped the dedupe ID.<\/li>\n\n\n\n<li><strong>&#8220;Published&#8221; isn&#8217;t &#8220;happened&#8221;.<\/strong> Upload dates, meeting dates and story dates are three different things. Say which one you mean.<\/li>\n\n\n\n<li><strong>Check the model&#8217;s work in code.<\/strong> Instructions reduce mistakes; checks catch the rest and tell the reviewer where to look.<\/li>\n\n\n\n<li><strong>A wrong name is worse than no name.<\/strong> When in doubt, &#8220;Speaker 3&#8221;.<\/li>\n\n\n\n<li><strong>Chunk long jobs.<\/strong> A four-hour recording in one pass ran out of memory; 30-minute pieces don&#8217;t.<\/li>\n\n\n\n<li><strong>Monitor what people came for,<\/strong> not just whether the server answers.<\/li>\n\n\n\n<li><strong>Keep a person in the loop.<\/strong> Review takes a minute a day, and it&#8217;s why we can trust what goes out.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\ud83d\udcac Want something like this?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Orting News is free, and it&#8217;ll stay free. The pieces underneath it are the same ones we use every day: polling and integrations, self-hosted AI, monitoring, and automation with a person in the loop. If your organization has information scattered across a dozen places, or meetings nobody has time to watch, <a href=\"https:\/\/rainier-it.com\/contact\/\">let&#8217;s talk<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You can read Orting News at <a href=\"https:\/\/shoporting.org\/news\/\" target=\"_blank\" rel=\"noopener\">shoporting.org\/news<\/a>. See a mistake? Every story has a link to tell us, and we&#8217;ll correct it, with a dated note.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>How Rainier IT built Orting News: hourly polling of public sources, council meetings transcribed with Whisper on our own GPU, AI summaries with guardrails, and a person approving every story.<\/p>\n","protected":false},"author":1,"featured_media":369,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[19,4,10],"tags":[18,12,48,47,46,42],"series":[68],"class_list":["post-371","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-guides","category-ai-chatbots","category-linux","tag-ai","tag-linux-2","tag-local-news","tag-monitoring","tag-orting","tag-pierce-county","series-how-we-built-it"],"_links":{"self":[{"href":"https:\/\/rainier-it.com\/blog\/wp-json\/wp\/v2\/posts\/371","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rainier-it.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rainier-it.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rainier-it.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/rainier-it.com\/blog\/wp-json\/wp\/v2\/comments?post=371"}],"version-history":[{"count":2,"href":"https:\/\/rainier-it.com\/blog\/wp-json\/wp\/v2\/posts\/371\/revisions"}],"predecessor-version":[{"id":373,"href":"https:\/\/rainier-it.com\/blog\/wp-json\/wp\/v2\/posts\/371\/revisions\/373"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rainier-it.com\/blog\/wp-json\/wp\/v2\/media\/369"}],"wp:attachment":[{"href":"https:\/\/rainier-it.com\/blog\/wp-json\/wp\/v2\/media?parent=371"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rainier-it.com\/blog\/wp-json\/wp\/v2\/categories?post=371"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rainier-it.com\/blog\/wp-json\/wp\/v2\/tags?post=371"},{"taxonomy":"series","embeddable":true,"href":"https:\/\/rainier-it.com\/blog\/wp-json\/wp\/v2\/series?post=371"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}