Repository navigation
Expand file tree
/
Copy pathrender.rb
More file actions
1327 lines (1156 loc) · 49.9 KB
/
Copy pathrender.rb
File metadata and controls
1327 lines (1156 loc) · 49.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env ruby
# frozen_string_literal: true
require 'erb'
require 'rss'
require 'httparty'
require 'json'
require 'time'
require 'fileutils'
require 'cgi'
require 'digest'
CACHE_FILE = 'cache/ai_summary_cache.json'
# Directory of last-good raw feed bodies. Many regional news sites sit behind a
# WAF that intermittently throttles GitHub's datacenter IPs (observed: HTTP
# 202/403/429 with an empty body minutes after a clean 200). When a live fetch
# is throttled, we fall back to the most recent successfully-fetched copy so
# readers see real content instead of an offline placeholder. Lives under the
# gitignored cache/ dir, which the workflow already persists across runs via
# actions/cache, so no workflow change is needed.
FEED_CACHE_DIR = 'cache/feeds'
# Fallback sources for a primary feed that is throttled/blocked from GitHub's
# datacenter IPs. Some regional papers (e.g. The Union) return HTTP 429/403 to
# the runner even though they work from residential IPs; when the primary has
# no live response and no cache, we pull the same beat from a runner-reachable
# alternate before showing an offline placeholder. Keyed by primary URL; the
# rendered card then naturally shows the alternate's own name/links.
FEED_FALLBACKS = {
'https://www.theunion.com/search/?f=rss&t=article&c=news' => [
'https://sierranevadaally.org/feed/'
]
}.freeze
# Optional per-feed item filters: keep only items for which the lambda returns
# true. InciWeb's RSS is national; we keep just California/Nevada incidents so
# the regional Fire section stays on-topic. Item titles are prefixed with the
# 2-letter state code (e.g. "CACNP …" / "NVELD …") and the description carries
# "State: California".
FEED_ITEM_FILTERS = {
'https://inciweb.wildfire.gov/incidents/rss.xml' => lambda do |item|
"#{item.title}".match?(/\A(?:CA|NV)/) ||
"#{item.description}".match?(/State:\s*(?:California|Nevada)/)
end
}.freeze
# Channel title stamped on the placeholder feed built for an offline/failed
# source (see create_offline_feed). Callers use feed_offline? to detect it.
FEED_OFFLINE_TITLE = 'Feed currently offline'
# Seconds to wait before the single feed re-fetch. A brief pause lets a
# transient upstream hiccup (rate-limit/blip from the CI runner IP) recover
# instead of flipping the feed to the offline placeholder. Override/disable
# with FEED_RETRY_DELAY (e.g. 0 in tests).
FEED_RETRY_DELAY = (ENV['FEED_RETRY_DELAY'] || '2').to_f
# Canned summarizer outputs that mean "no real content was produced". These
# must never be cached, or a degraded (e.g. all-feeds-offline) render would
# freeze for the whole cache TTL on rapid reruns.
PLACEHOLDER_SUMMARIES = [
'No content available for summarization.',
'No articles available for summarization.'
].freeze
# Shared newsroom contract for all summaries. These rules are intentionally
# explicit because small local models need stronger grounding than large hosted
# models.
SUMMARY_PROMPT_GUARDRAILS = <<~PROMPT.strip.freeze
Use only facts stated in the supplied items. Do not invent, infer, or connect separate items.
Each bracketed ITEM is independent unless its own text explicitly says otherwise.
Preserve names, places, dates, numbers, and operational status verbs exactly as written.
Treat jokes, asides, rhetorical questions, and parenthetical comments as non-factual and omit them.
Ignore slogans, teasers, calls to action, and promotional headline fragments unless the DESCRIPTION states concrete news facts.
Omit incomplete or truncated text, including text ending in an ellipsis. Never complete a cut-off phrase.
Do not add severity, importance, or promotional adjectives unless they appear in the supplied item.
Do not describe what residents know, feel, expect, or face unless an item states it.
Do not mention the feed, source, article, supplied text, or that you are summarizing.
Never begin with "The text", "This article", "These stories", "The following", or "In summary".
Do not end with a general sentence about what the stories reflect, highlight, or demonstrate.
Avoid filler such as "recent updates", "meanwhile", "additionally", "highlights", "showcases", "shines", or "makes headlines".
Return exactly one plain-text paragraph with no label, markdown, HTML, headings, bullets, links, or line breaks.
PROMPT
NEWS_SUMMARY_PROMPT = <<~PROMPT.strip.freeze
Write a local-news digest of at most 120 words.
The first sentence must report a specific event with a named subject and action, not announce that updates or stories exist.
Open with the most recent or consequential development, then briefly include only closely related or important items.
Use direct declarative sentences and active voice. If the items are thin or repetitive, write less rather than padding.
#{SUMMARY_PROMPT_GUARDRAILS}
PROMPT
OVERALL_SUMMARY_PROMPT = <<~PROMPT.strip.freeze
Write a front-page local-news digest of at most 150 words.
The first sentence must report a specific event with a named subject and action, not announce a digest or list of updates.
Lead with the most consequential development, then summarize other important developments in descending importance.
State concrete facts and clearly stated local impacts. Do not merge unrelated items into a single claim or invent a theme.
Use direct declarative sentences and active voice. If coverage is sparse, write less rather than padding.
#{SUMMARY_PROMPT_GUARDRAILS}
PROMPT
GROUNDED_FACTS_PROMPT = <<~PROMPT.strip.freeze
Extract concise news facts from independently labeled items.
Return only a JSON object with this shape:
{"facts":[{"item":1,"sentence":"One complete factual sentence."}]}
Write at most one sentence of 35 words per usable item. Use the ITEM number exactly.
Every sentence must describe only its matching ITEM. Never combine names, numbers, dates, places, or actions from different items.
Prefer concrete facts from DESCRIPTION. Use TITLE only to clarify the same item.
Report the newest concrete action, decision, result, or operational status. For a live incident, include its latest stated size or status instead of merely naming its location.
Do not merely restate a headline or introductory phrase. Avoid vague constructions such as "highlights", "showcases", "lays out", or "a look at".
Skip items that contain only a slogan, teaser, list of headlines, editorial note, or incomplete text.
Preserve every name, place, date, number, numeric format, and operational status exactly as supplied.
Do not add facts, context, transitions, labels, markdown, commentary, or keys other than "facts", "item", and "sentence".
PROMPT
BREAKING_SUMMARY_PROMPT = <<~PROMPT.strip.freeze
Write a breaking-news update of at most 70 words.
The first sentence must summarize the item labeled LATEST UPDATE.
Use only the LATEST UPDATE; the update list below the summary covers earlier events.
Prefer its original nouns and verbs over paraphrasing.
Do not add severity words such as "major" unless the latest item uses them.
Keep status verbs exactly as written; for example, never change "releasing" to "deploying".
Include a time only when it appears in the supplied item. Use terse, direct declarative sentences.
#{SUMMARY_PROMPT_GUARDRAILS}
PROMPT
# Maximum length of a friendly feed name before it is truncated with an
# ellipsis (see feed_display_name), so a long channel title can't dominate the
# layout or reintroduce horizontal overflow on narrow screens.
FEED_NAME_MAX = 40
# Maximum length of a single item's description fed to the summarizer (see
# item_description). Caps how much one verbose article can consume of the
# token-bounded content window, so several items still fit and the AI gets
# breadth as well as depth.
ITEM_DESC_MAX = 400
SUMMARY_ITEMS_PER_FEED = 6
OVERALL_ITEMS_PER_FEED = 2
BREAKING_SUMMARY_ITEMS = 1
# National Weather Service active-alerts API + the zone to watch. CAC057 is
# Nevada County, CA (covers Nevada City, Grass Valley and Truckee); override
# with NWS_ALERT_ZONE (any NWS county "CACnnn" or forecast/fire "CAZnnn" UGC).
# The band is the "high-signal, only-when-active" critical strip: it renders
# nothing when there are no qualifying alerts. NWS requires a descriptive
# User-Agent or it returns 403.
NWS_ALERTS_ENDPOINT = 'https://api.weather.gov/alerts/active'
NWS_ALERT_ZONE = (ENV['NWS_ALERT_ZONE'] || 'CAC057').strip
NWS_USER_AGENT = 'rss-firehose (https://github.com/djdefi/rss-firehose)'
# Only genuinely critical, actionable alerts belong in the band. Keep an alert
# if its severity is Severe/Extreme (NWS "Warnings") or its event matches one
# of these life-safety types, so routine advisories (Lake Wind, Heat Advisory)
# are filtered out.
NWS_CRITICAL_EVENTS = /red flag|fire warning|fire weather|evacuation|flood|tornado|tsunami|hurricane|extreme|blizzard|severe thunderstorm/i
NWS_CRITICAL_SEVERITIES = %w[Severe Extreme].freeze
# Cap how many alerts the band lists, newest first, so a busy alert day can't
# push the actual news far down the page.
NWS_ALERT_MAX = 6
def title
title = ENV['RSS_TITLE'] || 'News Firehose'
title.strip.empty? ? 'News Firehose' : title
end
def rss_urls
urls = if ENV['RSS_URLS']
ENV['RSS_URLS'].split(',').map(&:strip).reject(&:empty?)
else
begin
File.readlines('urls.txt').map(&:chomp).reject(&:empty?)
rescue Errno::ENOENT
puts "Warning: urls.txt not found, using backup URLs"
[]
end
end
# Validate URLs
valid_urls = urls.select do |url|
url.match?(/\Ahttps?:\/\//)
end
if valid_urls != urls
puts "Warning: Some invalid URLs were filtered out"
end
valid_urls.empty? ? rss_backup_urls : valid_urls
end
def rss_backup_urls
urls = if ENV['RSS_BACKUP_URLS']
ENV['RSS_BACKUP_URLS'].split(',').map(&:strip).reject(&:empty?)
else
['https://calmatters.org/feed/']
end
# Validate backup URLs
urls.select { |url| url.match?(/\Ahttps?:\/\//) }
end
# Feeds for the secondary "Regional & Fire" page (Truckee/Tahoe outlets +
# InciWeb fire incidents). Kept off the lean main page and off by default so
# the test suite stays hermetic/fast: enabled only when RSS_REGIONAL_URLS is set
# (comma-separated, takes precedence) or RENDER_REGIONAL=1 selects urls-regional.txt.
# The deploy workflow sets RENDER_REGIONAL=1. Every listed feed must be
# runner-verified (many regional feeds are datacenter-IP-blocked).
def regional_urls
raw = if ENV['RSS_REGIONAL_URLS']
ENV['RSS_REGIONAL_URLS'].split(',')
elsif ENV['RENDER_REGIONAL'] == '1' && File.exist?('urls-regional.txt')
File.readlines('urls-regional.txt')
else
[]
end
raw.map(&:strip).reject(&:empty?).select { |url| url.match?(%r{\Ahttps?://}i) }
end
def description
desc = ENV['RSS_DESCRIPTION'] || 'View the latest news.'
desc.strip.empty? ? 'View the latest news.' : desc
end
def analytics_ua
ENV['ANALYTICS_UA']
end
# Render a feed page from the shared template. output_path/page_title/show_nav
# let the same template drive both the main index and the secondary regional
# page; on the main page they default so the output is unchanged. show_nav emits
# the small two-link inter-page nav (static text, so it passes a11y linting).
def render_html(feeds, overall_summary, feed_summaries, breaking_news = [], breaking_news_summary = nil, weather_alerts = [],
output_path: 'public/index.html', page_title: nil, show_nav: false)
begin
html = File.open('templates/index.html.erb').read
template = ERB.new(html, trim_mode: '-')
File.open(output_path, 'w') do |f|
f.puts template.result(binding)
end
rescue => e
puts "Warning: Failed to render HTML. Error: #{e.message}"
end
end
def render_manifest
begin
json = File.open('templates/manifest.json.erb').read
template = ERB.new(json, trim_mode: '-')
File.open('public/manifest.json', 'w') do |f|
f.puts template.result(binding)
end
rescue => e
puts "Warning: Failed to manifest JSON. Error: #{e.message}"
end
end
# Fetch a feed URL and parse it, returning the RSS object or nil on ANY
# failure — non-200, network error (timeout/reset/DNS), or parse error. Kept
# separate from feed() so a transient failure of either kind can be retried by
# simply calling it again.
def fetch_and_parse_feed(url)
response = HTTParty.get(url, timeout: 60, headers: { 'User-Agent' => 'rss-firehose feed aggregator' })
return nil unless response.code == 200
parsed = RSS::Parser.parse(response.body, false)
# Only cache a body we know is good (parsed and non-empty) so a throttled
# response that happens to arrive as HTTP 200 can't overwrite the last-good copy.
save_cached_feed(url, response.body) if parsed && !parsed.items.empty?
parsed
rescue HTTParty::Error, RSS::Error => e
puts "Error fetching or parsing feed from '#{url}': #{e.class} - #{e.message}"
nil
rescue => e
puts "General error with feed '#{url}': #{e.message}"
nil
end
# Filesystem path of the last-good cached body for a feed URL. The URL is
# hashed so the filename is always filesystem-safe regardless of query strings.
def feed_cache_path(url)
File.join(FEED_CACHE_DIR, "#{Digest::SHA1.hexdigest(url.to_s)}.xml")
end
# Persist a known-good raw feed body for later reuse. Fails soft: a cache write
# problem must never break rendering.
def save_cached_feed(url, body)
return if body.nil? || body.to_s.empty?
FileUtils.mkdir_p(FEED_CACHE_DIR)
File.write(feed_cache_path(url), body)
rescue StandardError => e
puts "Could not cache feed '#{url}': #{e.message}"
end
# Load and parse the last-good cached body for a feed, or nil when there is no
# usable cache (missing file, unparseable, or empty).
def load_cached_feed(url)
path = feed_cache_path(url)
return nil unless File.exist?(path)
parsed = RSS::Parser.parse(File.read(path), false)
parsed if parsed && !parsed.items.empty?
rescue StandardError
nil
end
# Get the feeds and parse them. We don't validate because some feeds are
# malformed slightly and break the parser. A single retry (after a brief
# FEED_RETRY_DELAY) covers a transient upstream failure of any kind — a
# timeout/reset, a non-200, or an empty/partial response. When the primary is
# still down we degrade gracefully through: live fallback source → primary's
# last-good cache → fallback's cache → offline placeholder.
def feed(url, fallbacks = FEED_FALLBACKS.fetch(url, []))
rss_content = fetch_and_parse_feed(url)
if rss_content.nil? || rss_content.items.empty?
puts "Feed from '#{url}' returned no items, retrying once..."
sleep(FEED_RETRY_DELAY) if FEED_RETRY_DELAY.positive?
rss_content = fetch_and_parse_feed(url)
end
return rss_content if rss_content && !rss_content.items.empty?
# Primary is down. Priority: a FRESH alternate source beats a STALE cache, so
# the beat stays current — try live fallbacks, then the primary's last-good
# cache, then the fallbacks' caches, and only then an offline placeholder.
fallbacks.each do |fb_url|
fb = fetch_and_parse_feed(fb_url)
next unless fb && !fb.items.empty?
puts "Feed '#{url}' unavailable; using live fallback source '#{fb_url}'."
return fb
end
cached = load_cached_feed(url)
if cached
puts "Feed from '#{url}' failed after retry; serving last-good cached copy."
return cached
end
fallbacks.each do |fb_url|
fb_cached = load_cached_feed(fb_url)
next unless fb_cached
puts "Feed '#{url}' unavailable; serving cached fallback '#{fb_url}'."
return fb_cached
end
puts "Feed from '#{url}' failed after retry; using offline placeholder."
create_offline_feed(url)
end
# Create a placeholder RSS feed object for offline/failed feeds
def create_offline_feed(url)
rss_content = RSS::Rss.new('2.0')
rss_content.channel = RSS::Rss::Channel.new
rss_content.channel.title = FEED_OFFLINE_TITLE
rss_content.channel.link = url
rss_content.channel.description = "The feed from '#{url}' is currently offline or returned no items."
# Add a placeholder item
item = RSS::Rss::Channel::Item.new
item.title = "Feed offline: #{url}"
item.link = url
item.description = "This feed is currently unavailable."
rss_content.channel.items << item
rss_content
end
# True when the feed is the offline placeholder built by create_offline_feed.
# Its only "item" is the bare feed URL, which is useless (and actively harmful)
# to summarize: the AI model responds with a refusal like "I'm unable to access
# external websites", which then leaks to visitors. Summary callers skip these.
def feed_offline?(feed)
feed.respond_to?(:channel) &&
feed.channel.respond_to?(:title) &&
feed.channel.title == FEED_OFFLINE_TITLE
rescue StandardError
false
end
# Apply the block to each item across a bounded thread pool, returning an
# (unordered) Hash of { item => block_result }. Used to pipeline the one-shot's
# independent network I/O — feed fetches and the per-feed/overall/breaking AI
# summary calls — so wall-clock is roughly the slowest single call instead of
# the sum of them all. Concurrency is bounded (default 8, override with
# FEED_CONCURRENCY).
def parallel_map(items)
results = {}
return results if items.empty?
max_threads = (ENV['FEED_CONCURRENCY'] || '8').to_i
max_threads = 1 if max_threads < 1
worker_count = [max_threads, items.size].min
queue = Queue.new
items.each { |item| queue << item }
mutex = Mutex.new
workers = Array.new(worker_count) do
Thread.new do
loop do
item = begin
queue.pop(true)
rescue ThreadError
break
end
value = yield(item)
mutex.synchronize { results[item] = value }
end
end
end
workers.each(&:join)
results
end
# Fetch and parse all feeds concurrently. Feeds are independent network I/O, so
# parallelizing collapses the one-shot's fetch time from the sum of every
# request to roughly the slowest single feed. Input URL order is preserved and
# each feed still flows through feed(), so the retry/offline-placeholder
# behavior is unchanged — only wall-clock time drops.
def fetch_feeds(urls)
return {} if urls.empty?
results = parallel_map(urls) do |url|
begin
apply_item_filter(url, feed(url))
rescue StandardError => e
puts "Unexpected error fetching '#{url}': #{e.message}"
create_offline_feed(url)
end
end
# Preserve input order and drop nils, matching the previous
# `rss_urls.map { ... }.to_h.compact` behavior.
urls.each_with_object({}) do |url, ordered|
value = results[url]
ordered[url] = value unless value.nil?
end
end
# Apply any configured FEED_ITEM_FILTERS to a parsed feed in place, dropping the
# items the filter rejects. No-op for feeds without a filter or without items,
# so it's safe to call on every fetched feed.
def apply_item_filter(url, parsed)
filter = FEED_ITEM_FILTERS[url]
return parsed unless filter && parsed.respond_to?(:items) && parsed.items
parsed.items.delete_if { |item| !filter.call(item) }
parsed
rescue StandardError => e
puts "Warning: item filter failed for '#{url}': #{e.message}"
parsed
end
# HTML escape function to prevent XSS attacks
def html_escape(text)
return text unless text.is_a?(String)
text.gsub('&', '&')
.gsub('<', '<')
.gsub('>', '>')
.gsub('"', '"')
.gsub("'", ''')
end
# Normalize an item title across feed dialects: RSS2 exposes a String, Atom an
# object with #content. Always returns a String so callers can safely escape it.
def item_title(item)
raw = item.title
raw.respond_to?(:content) ? raw.content.to_s : raw.to_s
end
# Normalize an item link across feed dialects: RSS2 exposes a String, Atom an
# object with #href. Always returns a String.
def item_link(item)
raw = item.link
return raw.href.to_s if raw.respond_to?(:href)
return raw.content.to_s if raw.respond_to?(:content)
raw.to_s
end
# Normalize an item's summary/description across feed dialects (RSS2
# `description`, Atom `summary`/`content`), strip HTML tags and decode entities,
# collapse whitespace, and retain only complete text within the length cap.
# Gives the summarizer real article substance (not just the headline) while
# keeping the content window bounded.
# The raw value is length-capped before the tag regex runs so a pathological
# description can't cause quadratic backtracking, and full tags (e.g. a
# WordPress featured image tag with a long srcset) are stripped whole rather
# than leaking markup. Returns "" when the item has no usable description.
def item_description(item)
raw = nil
raw = item.description if item.respond_to?(:description) && item.description
raw = item.summary if raw.nil? && item.respond_to?(:summary) && item.summary
raw = item.content if raw.nil? && item.respond_to?(:content) && item.content
text = (raw.respond_to?(:content) ? raw.content.to_s : raw.to_s)[0, 4000]
text = CGI.unescapeHTML(text.gsub(/<[^>]*>/, ' ')).gsub(/\s+/, ' ')
text = text.gsub(/ +([.,;:!?])/, '\1').strip
return text if text.length <= ITEM_DESC_MAX && !text.end_with?('…') && !text.end_with?('...')
complete_sentences = split_summary_sentences(text).select do |sentence|
sentence.match?(/[.!?]\z/) && !sentence.end_with?('…') && !sentence.end_with?('...')
end
selected = []
complete_sentences.each do |sentence|
candidate = (selected + [sentence]).join(' ')
break if candidate.length > ITEM_DESC_MAX
selected << sentence
end
selected.join(' ')
end
# Allow only safe URL schemes in generated hrefs so a malicious feed can't inject
# javascript:/data:/vbscript: links. Absolute, protocol-relative, root-relative
# and anchor links pass through; anything else collapses to '#'.
def safe_url(url)
str = url.to_s.strip
str.match?(%r{\A(?:https?://|ftp://|mailto:|//|/|\#)}i) ? str : '#'
end
# Bare hostname for a feed URL: strips scheme, leading "www." and any path, so
# "https://www.example.com/rss?x=1" becomes "example.com". Falls back to the
# original string if stripping leaves it empty.
def feed_host(url)
host = url.to_s.sub(%r{\Ahttps?://}i, '').sub(/\Awww\./i, '').sub(%r{/.*\z}m, '')
host.empty? ? url.to_s : host
end
# Friendly, short label for a feed source shown in the rendered page: prefer the
# RSS channel title (e.g. "YubaNet"), falling back to the bare hostname when the
# title is missing, the feed is offline, the title itself looks like a URL/domain
# (some feeds title themselves "www.example.com - RSS Results ..."), or the title
# is unreasonably long. Keeps the listing clean and avoids long raw URLs
# overflowing mobile viewports.
def feed_display_name(url, parsed)
title = (parsed.channel.title.to_s.strip if parsed.respond_to?(:channel) && parsed.channel)
return feed_host(url) if title.nil? || title.empty? ||
title == FEED_OFFLINE_TITLE ||
title.match?(%r{\A(?:https?://|www\.)}i)
title.length > FEED_NAME_MAX ? "#{title[0, FEED_NAME_MAX].rstrip}…" : title
rescue StandardError
feed_host(url)
end
def sanitize_response(response_body)
JSON.parse(response_body)
rescue JSON::ParserError => e
puts "JSON parsing error: #{e.message}"
nil
end
# Secure version of convert_markdown_links_to_html that prevents ReDoS attacks
def convert_markdown_links_to_html(text)
# Use a more specific regex that avoids catastrophic backtracking
# This pattern ensures we match only well-formed markdown links
text.gsub(/\[([^\]]{1,100})\]\(([^)\s]{1,200})\)/) do |match|
link_text = html_escape($1)
url = $2
# Additional safety: ensure URL uses safe protocols
if url.match?(/\A(https?|ftp):\/\//)
"<a href=\"#{html_escape(url)}\">#{link_text}</a>"
else
match # Return original text if URL doesn't look safe
end
end
end
AI_SUMMARY_MODEL = 'lfm2.5-2.6b'
SUMMARY_PIPELINE_VERSION = 'grounded-v7'
def configured_ai_model
ENV.fetch('AI_MODEL', AI_SUMMARY_MODEL)
end
# Enforce the one-paragraph plain-text output contract before HTML rendering.
# The prefix cleanup is a deterministic backstop for common small-model
# preambles that the prompt explicitly forbids.
def split_summary_sentences(text)
chars = text.each_char.to_a
sentences = []
start_index = 0
chars.each_index do |index|
next unless ['.', '!', '?'].include?(chars[index])
next unless index == chars.length - 1 || chars[index + 1].strip.empty?
next if chars[index] == '.' && summary_abbreviation_period?(chars, index)
sentence = chars[start_index..index].join.strip
sentences << sentence unless sentence.empty?
start_index = index + 1
end
tail = chars[start_index..]&.join.to_s.strip
sentences << tail unless tail.empty?
sentences
end
def summary_abbreviation_period?(chars, index)
prefix = chars[0...index].join
token = prefix.split.last.to_s.downcase
abbreviations = %w[
a.m apr aug calif dec dr feb jan jul jun mar mr mrs ms nev no nov oct p.m sep sept st u.s
]
abbreviations.include?(token) || (token.length == 1 && token.match?(/[a-z]/))
end
def truncate_summary_sentences(text, max_words)
return text if max_words.nil? || text.split.size <= max_words
sentences = split_summary_sentences(text)
selected = []
word_count = 0
sentences.each do |sentence|
sentence_words = sentence.split.size
break if word_count + sentence_words > max_words
selected << sentence
word_count += sentence_words
end
return selected.join(' ') unless selected.empty?
"#{text.split.first(max_words).join(' ').sub(/[,:;]\z/, '')}."
end
def strip_generic_summary_closer(summary)
sentences = split_summary_sentences(summary)
return summary if sentences.length < 2
closer = sentences.last.downcase
generic_starts = [
'these developments reflect',
'these developments highlight',
'these developments show',
'these developments demonstrate',
'these stories reflect',
'these stories highlight',
'these stories show',
'these stories demonstrate',
'these updates reflect',
'these updates highlight',
'these updates show',
'these updates demonstrate',
'those developments reflect',
'those stories reflect',
'those updates reflect'
]
return summary unless generic_starts.any? { |prefix| closer.start_with?(prefix) }
sentences[0...-1].join(' ')
end
def strip_incomplete_summary_sentences(summary)
sentences = split_summary_sentences(summary)
sentences.reject! { |sentence| sentence.include?('…') || sentence.include?('...') }
sentences.pop while sentences.length > 1 && !sentences.last.match?(/[.!?]\z/)
sentences.join(' ')
end
def deduplicate_summary_sentences(summary)
seen = {}
split_summary_sentences(summary).each_with_object([]) do |sentence, unique|
normalized = sentence.downcase.split.join(' ')
next if seen[normalized]
seen[normalized] = true
unique << sentence
end.join(' ')
end
def format_summary(text, max_words: nil)
summary = text.to_s.split.join(' ')
summary = summary.sub(/\A(?:summary:\s*)/i, '')
summary = summary.sub(/\Athe (?:supplied )?text (?:highlights|describes|reports|covers|notes|discusses)\s+/i, '')
summary = summary.sub(/\Arecent updates (?:highlight|include)\s+/i, '')
summary = summary.sub(/\A[^.:]{1,80}\bis seeing several key updates:\s*/i, '')
summary = strip_incomplete_summary_sentences(summary)
summary = deduplicate_summary_sentences(summary)
summary = strip_generic_summary_closer(summary)
summary = summary.gsub(/\[([^\]]{1,100})\]\([^)[:space:]]{1,200}\)/, '\1')
summary = summary.gsub(/\*\*([^*]{1,200})\*\*/, '\1')
summary = summary.sub(/\A\#{1,6}\s*/, '')
summary = summary.sub(/\A([a-z])/) { Regexp.last_match(1).upcase }
summary = truncate_summary_sentences(summary, max_words)
html_escape(summary.strip)
end
# Request a chat completion from the local llama.cpp server started by the
# Pages workflow. No API key or hosted inference service is required.
def request_ai_completion(system_prompt, user_content, context:, temperature:, max_tokens:, top_p:,
response_format: nil)
endpoint = ENV['AI_API_ENDPOINT'].to_s.strip
if endpoint.empty?
puts "No local AI endpoint configured, skipping AI summarization"
return { error: "AI summarization unavailable - local model not configured." }
end
headers = { "Content-Type" => "application/json" }
request_body = {
"messages": [
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": user_content }
],
"model": configured_ai_model,
"temperature": temperature,
"max_tokens": max_tokens,
"top_p": top_p
}
request_body["response_format"] = response_format if response_format
response = HTTParty.post(
endpoint,
headers: headers,
timeout: (ENV['AI_REQUEST_TIMEOUT'] || '180').to_i,
body: request_body.to_json
)
if !response.success? && response_format
puts "AI service rejected structured output for #{context}; retrying with prompt-enforced JSON"
request_body.delete("response_format")
response = HTTParty.post(
endpoint,
headers: headers,
timeout: (ENV['AI_REQUEST_TIMEOUT'] || '180').to_i,
body: request_body.to_json
)
end
unless response.success?
puts "AI service returned HTTP #{response.code} while summarizing #{context}"
return { error: "Summary generation failed - AI service returned HTTP #{response.code}." }
end
parsed_response = sanitize_response(response.body)
content = parsed_response&.dig("choices", 0, "message", "content")
return { error: "Summary generation failed - no valid response from AI service." } if content.to_s.strip.empty?
{ content: content.to_s }
rescue HTTParty::Error => e
puts "HTTP error summarizing #{context}: #{e.message}"
{ error: "Summary unavailable due to network error." }
rescue => e
puts "General error summarizing #{context}: #{e.message}"
{ error: "Summary generation failed due to technical error." }
end
def generate_ai_summary(system_prompt, user_content, context:, temperature:, max_tokens:, top_p:, max_words:)
result = request_ai_completion(
system_prompt,
user_content,
context: context,
temperature: temperature,
max_tokens: max_tokens,
top_p: top_p
)
return result[:error] if result[:error]
format_summary(result[:content], max_words: max_words)
end
def summary_number_tokens(text)
text.to_s.scan(/\d+(?:[.,]\d+)*/).map { |number| number.delete(',') }
end
def valid_grounded_fact?(sentence, source)
return false if sentence.empty? || sentence.include?('…') || sentence.include?('...')
return false unless sentence.match?(/[.!?]\z/)
return false unless split_summary_sentences(sentence).length == 1
return false if sentence.split.length > 35 || sentence.split.length < 4
return false if sentence.start_with?('?')
return false if sentence.match?(/\b(?:a look at|highlights|lays out|located on|questionable origin story|showcases)\b/i)
return false if sentence.match?(/\b(?:you|your)\b/i)
return false if low_signal_grounded_fact?(sentence)
(summary_number_tokens(sentence) - summary_number_tokens(source)).empty?
end
def low_signal_grounded_fact?(sentence)
normalized = sentence.downcase
starts = [
'all candidates and information are subject to',
'advance registration is required',
'however, when it comes',
'releasing two tankers',
'that’s exactly how it should be',
'that’s kind of a big deal',
"that's exactly how it should be",
"that's kind of a big deal",
'tomorrow morning, thousands of people',
'view the agenda',
"we're tracking how",
'we’re tracking how'
]
phrases = [
'all the info on what lies ahead',
'are excited to stage',
'highly questionable origin',
'reveal new dimensions',
'surprising artistic background',
'won’t think twice',
"won't think twice"
]
starts.any? { |prefix| normalized.start_with?(prefix) } ||
phrases.any? { |phrase| normalized.include?(phrase) } ||
normalized.match?(/\A\d+\s+to\b/)
end
def parse_grounded_facts(content, lines)
parsed = JSON.parse(content.to_s.strip)
facts = parsed['facts']
return [] unless facts.is_a?(Array)
seen = {}
facts.filter_map do |fact|
next unless fact.is_a?(Hash)
item_number = fact['item']
sentence = fact['sentence'].to_s.split.join(' ')
next unless item_number.is_a?(Integer) && item_number.between?(1, lines.length)
next if seen[item_number] || !valid_grounded_fact?(sentence, lines[item_number - 1])
seen[item_number] = true
sentence
end
rescue JSON::ParserError => e
puts "Grounded fact JSON parsing error: #{e.message}"
[]
end
def fallback_grounded_fact(line)
description = line.split(' - ', 2)[1].to_s
return if description.empty?
split_summary_sentences(description).reverse.find do |sentence|
valid_grounded_fact?(sentence, line)
end
end
def generate_grounded_facts(lines, context:)
return { facts: [], error: "No articles available for summarization." } if lines.empty?
errors = []
facts = lines.each_with_index.filter_map do |line, index|
result = request_ai_completion(
GROUNDED_FACTS_PROMPT,
labeled_summary_content([line], 1024),
context: "#{context} item #{index + 1}",
temperature: 0.0,
max_tokens: 120,
top_p: 0.8,
response_format: { "type" => "json_object" }
)
if result[:error]
errors << result[:error]
next fallback_grounded_fact(line) unless ENV['AI_API_ENDPOINT'].to_s.strip.empty?
next
end
parse_grounded_facts(result[:content], [line]).first || fallback_grounded_fact(line)
end
if facts.empty?
{ facts: [], error: errors.first || "Summary generation failed - no grounded facts returned." }
else
{ facts: facts, error: nil }
end
end
def assemble_grounded_summary(facts, max_words:)
return "No articles available for summarization." if facts.empty?
format_summary(facts.join(' '), max_words: max_words)
end
def interleave_grounded_facts(fact_sets)
max_size = fact_sets.map(&:length).max.to_i
(0...max_size).flat_map do |index|
fact_sets.filter_map { |facts| facts[index] }
end
end
def assemble_fact_results(results, keys, max_words:)
fact_sets = keys.filter_map do |key|
result = results[key]
result[:facts].first(3) unless result[:error]
end
facts = interleave_grounded_facts(fact_sets)
facts = split_summary_sentences(deduplicate_summary_sentences(facts.join(' ')))
return "Summary generation failed - no grounded facts returned." if facts.empty?
assemble_grounded_summary(facts, max_words: max_words)
end
def summarize_news(feed)
return "No content available for summarization." if feed.nil?
# An offline placeholder feed has no real content to summarize; returning nil
# lets the template hide the per-feed summary box entirely instead of leaking
# an AI refusal ("I'm unable to access external websites") to visitors.
return nil if feed_offline?(feed)
lines = if feed.is_a?(Array)
feed.flat_map { |f| extract_feed_content(f) }
else
extract_feed_content(feed)
end
result = generate_grounded_facts(deduplicate_summary_lines(lines), context: "news")
return result[:error] if result[:error]
assemble_grounded_summary(result[:facts], max_words: 120)
end
def summarize_overall_news(feeds)
return "No content available for summarization." if feeds.nil? || feeds.empty?
# Skip offline placeholders so the bare feed URL never pollutes the
# front-page summary (or makes the model refuse to summarize it).
live_feeds = feeds.reject { |feed| feed_offline?(feed) }
all_lines = live_feeds.flat_map { |feed| extract_feed_content(feed, limit: OVERALL_ITEMS_PER_FEED) }
result = generate_grounded_facts(deduplicate_summary_lines(all_lines), context: "overall news")
return result[:error] if result[:error]
assemble_grounded_summary(result[:facts], max_words: 150)
end
# Normalize an item timestamp across RSS and Atom dialects.
def item_published_at(item)
raw = if item.respond_to?(:date) && item.date
item.date
elsif item.respond_to?(:pubDate) && item.pubDate
item.pubDate
elsif item.respond_to?(:published) && item.published
item.published
elsif item.respond_to?(:updated) && item.updated
item.updated
end
raw = raw.content if raw.respond_to?(:content)
raw.is_a?(Time) ? raw : Time.parse(raw.to_s)
rescue ArgumentError, TypeError
nil
end
# Extract recent items as "Title - description" lines. Dated entries are sorted
# newest first; undated entries retain feed order. URLs are deliberately omitted.
def extract_feed_content(feed, limit: SUMMARY_ITEMS_PER_FEED)
return [] if feed.nil? || !feed.respond_to?(:items) || feed.items.nil?
sorted_items = feed.items.each_with_index.sort_by do |item, index|
published_at = item_published_at(item)
published_at ? [0, -published_at.to_f, index] : [1, 0, index]
end.map(&:first)
sorted_items.filter_map do |item|
title = CGI.unescapeHTML(item_title(item).to_s).split.join(' ')
desc = item_description(item)
next if composite_digest_title?(title)
next if editorial_note_only?(desc)
next if desc.empty? && promotional_title?(title)
text = desc.empty? ? title : "#{title} - #{desc}"
text unless text.empty?
end.first(limit)
rescue => e
puts "Error extracting feed content: #{e.message}"
[]
end
# Join only complete item lines within a character budget so model input never
# ends with a truncated headline or sentence fragment.
def bounded_summary_content(lines, max_chars)
selected = []
length = 0
lines.each do |line|
added_length = line.length + (selected.empty? ? 0 : 2)
break if length + added_length > max_chars
selected << line
length += added_length
end
selected.join('. ')
end
def labeled_summary_content(lines, max_chars)
labeled_lines = lines.each_with_index.map do |line, index|
title, desc = line.split(' - ', 2)
fields = ["[ITEM #{index + 1}] TITLE: #{title}"]
fields << "DESCRIPTION: #{desc}" unless desc.to_s.empty?
fields.join(' | ')
end
bounded_summary_content(labeled_lines, max_chars)
end
def promotional_title?(title)
title.include?('!') &&
title.match?(/\b(?:apply|attend|buy|donate|join|register|subscribe|support|vote)\b/i)
end
def composite_digest_title?(title)
title.scan(/;\s+\S/).length >= 2 || title.match?(/;\s*more\z/i)
end