Module: SqlChatbot::Grammar::CountRenderer

Defined in:
lib/sql_chatbot/grammar/count_renderer.rb

Overview

Programmatic renderer for the grammar’s COUNT primitive. Bypasses the answer-stream LLM entirely — a single number is too easy for the LLM to mis-narrate (e.g., rendering ‘count = 0` as “No matching records found” instead of “There are 0 X”).

Mirrors the architectural shape of ‘ListRenderer`:

- Pure function. No side effects, no LLM calls.
- Returns { ok: true, text: "..." } when conditions match, else { ok: false }.
- Caller (Orchestrator) yields the text as `token` events when ok.

Conditions for programmatic render:

- primitive == :COUNT (or "COUNT")
- exactly one result row
- that row has a numeric (or numeric-stringable) `count` field — the
  standard column shape PostgreSQL emits for `SELECT COUNT(*)`.

Anything else (grouped counts, multi-row results) falls through to the answer LLM.

Constant Summary collapse

IRREGULAR_PLURALS =
{
  "people" => "person", "men" => "man", "women" => "woman", "children" => "child",
  "feet" => "foot", "teeth" => "tooth", "geese" => "goose", "mice" => "mouse",
  "analyses" => "analysis", "bases" => "basis", "crises" => "crisis", "theses" => "thesis",
  "data" => "datum", "criteria" => "criterion", "phenomena" => "phenomenon",
}.freeze

Class Method Summary collapse

Class Method Details

.already_plural?(word) ⇒ Boolean

Returns:

  • (Boolean)


62
63
64
65
66
67
68
# File 'lib/sql_chatbot/grammar/count_renderer.rb', line 62

def self.already_plural?(word)
  # A word is already plural if singularize produces a different word.
  # "credentials" -> "credential" (different) -> plural ✓
  # "class"       -> "class" (ss-guard, unchanged) -> not plural ✓
  # "inbox"       -> "inbox" (no s ending) -> not plural ✓
  singularize_word(word) != word
end

.as_number(v) ⇒ Object



40
41
42
43
44
45
46
47
# File 'lib/sql_chatbot/grammar/count_renderer.rb', line 40

def self.as_number(v)
  case v
  when Integer then v
  when Numeric then v.to_i
  when String  then (v =~ /\A-?\d+\z/) ? v.to_i : nil
  else nil
  end
end

.match_case(original, transformed) ⇒ Object



80
81
82
83
84
85
86
87
# File 'lib/sql_chatbot/grammar/count_renderer.rb', line 80

def self.match_case(original, transformed)
  return transformed if original.empty? || transformed.empty?
  if original[0] == original[0].upcase
    transformed[0].upcase + transformed[1..]
  else
    transformed
  end
end

.pluralize_word(word) ⇒ Object



89
90
91
92
93
94
# File 'lib/sql_chatbot/grammar/count_renderer.rb', line 89

def self.pluralize_word(word)
  if word =~ /(s|x|z|ch|sh)\z/i           then word + "es"
  elsif word =~ /[^aeiouAEIOU]y\z/        then word[0..-2] + "ies"
  else                                         word + "s"
  end
end

.singularize_word(word) ⇒ Object



103
104
105
106
107
108
109
110
# File 'lib/sql_chatbot/grammar/count_renderer.rb', line 103

def self.singularize_word(word)
  return word if word.empty?
  return IRREGULAR_PLURALS[word] if IRREGULAR_PLURALS.key?(word)
  return word[0..-4] + "y" if word.length > 3 && word.end_with?("ies")
  return word[0..-3] if word =~ /(sses|xes|zes|ches|shes)\z/
  return word[0..-2] if word.end_with?("s") && !word.end_with?("ss")
  word
end

.to_plural_label(label) ⇒ Object

Force the LAST word of a (possibly multi-word) label to plural. Detects already-plural inputs (“Credentials” → singularize gives “credential” — different — so it’s already plural) to avoid the “Credentialses” double-pluralization.



53
54
55
# File 'lib/sql_chatbot/grammar/count_renderer.rb', line 53

def self.to_plural_label(label)
  transform_last_word(label) { |w| already_plural?(w) ? w : pluralize_word(w) }
end

.to_singular_label(label) ⇒ Object

Force the LAST word to singular. Mirror of ‘to_plural_label`.



58
59
60
# File 'lib/sql_chatbot/grammar/count_renderer.rb', line 58

def self.to_singular_label(label)
  transform_last_word(label) { |w| already_plural?(w) ? singularize_word(w) : w }
end

.transform_last_word(label) ⇒ Object



70
71
72
73
74
75
76
77
78
# File 'lib/sql_chatbot/grammar/count_renderer.rb', line 70

def self.transform_last_word(label)
  parts = label.split(" ")
  last = parts.last
  return label if last.nil? || last.empty?
  lower = last.downcase
  transformed = yield(lower)
  parts[-1] = match_case(last, transformed)
  parts.join(" ")
end

.try_render(primitive, entity_display_label, rows) ⇒ Object



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# File 'lib/sql_chatbot/grammar/count_renderer.rb', line 23

def self.try_render(primitive, entity_display_label, rows)
  return { ok: false } unless primitive.to_s == "COUNT"
  return { ok: false } unless rows.is_a?(Array) && rows.length == 1

  row = rows.first
  return { ok: false } unless row.is_a?(Hash)

  v = row["count"] || row[:count]
  n = as_number(v)
  return { ok: false } if n.nil?

  label  = entity_display_label.to_s.empty? ? "Item" : entity_display_label.to_s
  noun   = n == 1 ? to_singular_label(label) : to_plural_label(label)
  verb   = n == 1 ? "is" : "are"
  { ok: true, text: "There #{verb} #{n} #{noun}." }
end