Module: SqlChatbot::Grammar::TemplateCompiler
- Defined in:
- lib/sql_chatbot/grammar/template_compiler.rb
Class Method Summary collapse
- .column_already_filtered?(sql, table, col) ⇒ Boolean
- .compile(intent, registry) ⇒ Object
- .default_timestamp_field(entity) ⇒ Object
-
.normalize_intent_shape(modifiers, entity) ⇒ Object
V1.3-K shape-repair pass — mirror of TS normalizeIntentShape.
- .repair_one(modifier, entity) ⇒ Object
-
.with_implicit_filters(sql, entity) ⇒ Object
Append every implicit WHERE clause for this entity.
Class Method Details
.column_already_filtered?(sql, table, col) ⇒ Boolean
120 121 122 123 |
# File 'lib/sql_chatbot/grammar/template_compiler.rb', line 120 def self.column_already_filtered?(sql, table, col) return true if sql.include?(Primitives.qc(table, col)) /\b#{Regexp.escape(table)}\.#{Regexp.escape(col)}\b/i.match?(sql) end |
.compile(intent, registry) ⇒ Object
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 |
# File 'lib/sql_chatbot/grammar/template_compiler.rb', line 10 def self.compile(intent, registry) return { ok: false, reason: "unmatched: #{intent[:reason]}" } if intent[:status].to_s == "unmatched" entity_name = registry.aliases[intent[:entity]] || intent[:entity] entity = registry.entities[entity_name] return { ok: false, reason: "entity '#{intent[:entity]}' not in registry" } unless entity begin modifiers = Array(intent[:modifiers]) primitive_sym = intent[:primitive].to_s rank_field = intent[:rank_field] limit_n = intent[:n] # V1.3-K shape-repair pass — three real-world LLM emit-fidelity issues # captured during the 2026-04-28 hard sweep. Mirror of TS # `normalizeIntentShape` in template-compiler.ts. modifiers = normalize_intent_shape(modifiers, entity) # TOP_N / MIN_MAX express the rank/field through an `order_by` # modifier when the LLM phrases it that way ("biggest repo by stars" # → primitive=TOP_N + order_by(num_stars,desc)). Lift the # modifier's field into the primitive slot and drop it so the # compiler doesn't emit ORDER BY twice (which produces a Postgres # syntax error and silently pushes the question to the LLM # fallback path). Write the lifted values back to the intent so # downstream consumers (programmatic renderer) can read the # canonical rank_field/field regardless of which surface the LLM # emitted. field = intent[:field] which = intent[:which] direction = intent[:direction] if primitive_sym == "TOP_N" || primitive_sym == "MIN_MAX" order_mod = modifiers.find { |m| (m[:kind] || m["kind"]).to_s == "order_by" } if order_mod lifted_field = order_mod[:field] || order_mod["field"] raw_dir = order_mod[:direction] || order_mod["direction"] || order_mod[:op] || order_mod["op"] || "desc" if primitive_sym == "TOP_N" rank_field ||= lifted_field # V1.3-U: pass through the lifted direction so "smallest X" # becomes ASC, mirroring TS liftOrderByForRank. direction ||= raw_dir.to_s.downcase else field ||= lifted_field which ||= raw_dir.to_s.downcase == "asc" ? :MIN : :MAX end modifiers = modifiers.reject { |m| m.equal?(order_mod) } end limit_mod = modifiers.find { |m| (m[:kind] || m["kind"]).to_s == "limit" } if limit_mod && primitive_sym == "TOP_N" limit_n ||= limit_mod[:value] || limit_mod["value"] modifiers = modifiers.reject { |m| m.equal?(limit_mod) } end intent[:rank_field] = rank_field intent[:field] = field intent[:which] = which intent[:direction] = direction intent[:modifiers] = modifiers intent[:n] = limit_n end sql = Primitives.build( primitive: intent[:primitive], entity: entity, field: field, which: which, n: limit_n, rank_field: rank_field, direction: direction, group_by: intent[:group_by] ) modifiers.each do |m| sql = Modifiers.apply(sql, m, entity) end sql = with_implicit_filters(sql, entity) unless sql =~ /LIMIT \d+/i || primitive_sym == "COUNT" || sql =~ /COUNT\(/i sql = "#{sql} LIMIT 100" end { ok: true, sql: sql } rescue => e { ok: false, reason: e. } end end |
.default_timestamp_field(entity) ⇒ Object
172 173 174 175 176 |
# File 'lib/sql_chatbot/grammar/template_compiler.rb', line 172 def self.(entity) return "created_at" if entity.fields["created_at"] ts_fields = entity.fields.values.select { |f| f.type.to_s == "timestamp" } ts_fields.length == 1 ? ts_fields.first.column : nil end |
.normalize_intent_shape(modifiers, entity) ⇒ Object
V1.3-K shape-repair pass — mirror of TS normalizeIntentShape.
1. `where` modifier whose value is a WINDOWS keyword
→ coerce to a `time` modifier with that window.
2. `time` modifier where the LLM used `value` for the keyword
instead of `window` → alias the key.
3. `time` modifier missing field entirely → default to created_at
if it exists, else the lone timestamp column.
135 136 137 |
# File 'lib/sql_chatbot/grammar/template_compiler.rb', line 135 def self.normalize_intent_shape(modifiers, entity) modifiers.map { |m| repair_one(m, entity) } end |
.repair_one(modifier, entity) ⇒ Object
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 |
# File 'lib/sql_chatbot/grammar/template_compiler.rb', line 139 def self.repair_one(modifier, entity) kind = (modifier[:kind] || modifier["kind"]).to_s value = modifier[:value] || modifier["value"] field = modifier[:field] || modifier["field"] window = modifier[:window] || modifier["window"] # Rule 1: where with WINDOWS keyword → time if kind == "where" && value.is_a?(String) && SqlChatbot::Grammar::Modifiers::WINDOWS.key?(value.downcase) return { kind: "time", field: field || (entity) || "", window: value.downcase, } end # Rule 2: time with `value` instead of `window` if kind == "time" && (window.nil? || window.to_s.empty?) && value.is_a?(String) && SqlChatbot::Grammar::Modifiers::WINDOWS.key?(value.downcase) return { kind: "time", field: field || (entity) || "", window: value.downcase, } end # Rule 3: time missing field → default if kind == "time" && (field.nil? || field.to_s.empty?) && window fallback = (entity) return { kind: "time", field: fallback, window: window } if fallback end modifier end |
.with_implicit_filters(sql, entity) ⇒ Object
Append every implicit WHERE clause for this entity. Two sources land in the same list: schema-detected soft-delete columns and developer- declared ‘default_filters`. A clause is skipped when its column is already referenced in the generated SQL — explicit user filters always win over the implicit default.
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
# File 'lib/sql_chatbot/grammar/template_compiler.rb', line 102 def self.with_implicit_filters(sql, entity) filters = Array(entity.respond_to?(:implicit_filters) ? entity.implicit_filters : nil) return sql if filters.empty? to_apply = filters.reject { |f| column_already_filtered?(sql, entity.table, f.column) } return sql if to_apply.empty? combined = to_apply.map { |f| "#{Primitives.qc(entity.table, f.column)} #{f.expr}" }.join(" AND ") if /\bWHERE\b/i.match?(sql) return sql.sub(/\bWHERE\b/i) { "WHERE #{combined} AND " } end before_match = sql.match(/ (GROUP BY|ORDER BY|LIMIT) /i) if before_match return sql.sub(before_match[0]) { " WHERE #{combined}#{before_match[0]}" } end "#{sql} WHERE #{combined}" end |