Module: Insika::BalloonSplitter

Defined in:
lib/insika/balloon_splitter.rb

Overview

— pure: confirmed answer text -> the balloons a progressive channel should POST. Paragraphs are the unit (\n\n is the seam E1 cares about); the sentence split fires only as a SOFT cap for one paragraph that grew past SENTENCE_AFTER. It never splits inside a fenced code block.

No events, no outbox, no :intermediate — if it is handed loop narration that is a caller bug; the splitter has no way to tell.

Constant Summary collapse

SENTENCE_AFTER =

Soft cap after which a single paragraph is split on sentences. WhatsApp's hard cap is ~4096; this is a readability cap, not a platform one.

600

Class Method Summary collapse

Class Method Details

.closes_fence?(piece, opener: false) ⇒ Boolean

Does this paragraph close an open fence? Any line starting with the fence marker counts. opener: true skips the FIRST line — the line that opened the block, which cannot be its own closer.

Returns:

  • (Boolean)


65
66
67
68
# File 'lib/insika/balloon_splitter.rb', line 65

def closes_fence?(piece, opener: false)
  lines = piece.lines
  lines[(opener ? 1 : 0)..].any? { |line| line.start_with?(FENCE) }
end

.group_fenced(paragraphs) ⇒ Object

A fenced code block (``````) spans paragraphs and stays ONE balloon — newlines inside it are code, not a seam. A fence that never closes still ends as one balloon (garbage in, one balloon out).



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
# File 'lib/insika/balloon_splitter.rb', line 31

def group_fenced(paragraphs)
  balloons = []
  fence = nil
  paragraphs.each do |para|
    piece = para.strip
    next if piece.empty?

    if fence
      fence << piece
      # a line starting with ``` closes the block, wherever it lands — the
      # close fence of ordinary markdown shares a paragraph with its code.
      if closes_fence?(piece)
        balloons << fence.join("\n\n")
        fence = nil
      end
    elsif piece.lines.first.to_s.start_with?(FENCE)
      fence = [piece]
      # one paragraph may open AND close the block (```ruby\nx = 1\n```) —
      # the closer is a later line of the same piece.
      if closes_fence?(piece, opener: true)
        balloons << fence.join("\n\n")
        fence = nil
      end
    else
      balloons << piece
    end
  end
  balloons << fence.join("\n\n") if fence
  balloons
end

.split(text) ⇒ Object

-> [String], at least one when text is non-blank, else [].



19
20
21
22
23
24
25
26
# File 'lib/insika/balloon_splitter.rb', line 19

def split(text)
  raw = Insika::Coercion.presence(text)
  return [] if raw.nil?

  paragraphs = raw.split(PARAGRAPH_BREAK)
  balloons = group_fenced(paragraphs)
  balloons.flat_map { |balloon| split_long(balloon) }
end

.split_long(balloon) ⇒ Object

The soft cap: a paragraph longer than SENTENCE_AFTER splits after sentence closers followed by whitespace — which is why 3.9s and www. survive (their period is not followed by whitespace) and a decimal does too. A leftover without a closer stays one balloon.

Sentences are then RE-GROUPED into ~SENTENCE_AFTER blocks ( the split is a cap, not a mandate). Without the regroup, a 680-char paragraph of short sentences would atomize into one balloon per sentence — a paragraph that was ONE bubble becomes 40 WhatsApp messages for no latency win.



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/insika/balloon_splitter.rb', line 80

def split_long(balloon)
  return [balloon] if balloon.length <= SENTENCE_AFTER

  parts = balloon.split(SENTENCE_BOUNDARY).map(&:strip).reject(&:empty?)
  return [balloon] if parts.empty?

  parts.each_with_object([+""]) do |sentence, balloons|
    if balloons.last.empty?
      balloons.last << sentence
    elsif balloons.last.length + sentence.length + 1 <= SENTENCE_AFTER
      balloons.last << " #{sentence}"
    else
      balloons << +sentence
    end
  end
end