Class: Assistant::PostgresChatToolBuilder
- Inherits:
-
Object
- Object
- Assistant::PostgresChatToolBuilder
- Defined in:
- app/services/assistant/postgres_chat_tool_builder.rb
Overview
Builds RubyLLM::Tool subclasses for PostgreSQL database access.
Provides execute_sql, schema exploration, and activity notes search.
Named PostgresChatToolBuilder to distinguish from ::PostgresToolBuilder
(MCP server tools in app/mcp/).
Usage (via ChatToolBuilder):
tools = Assistant::PostgresChatToolBuilder.tools(
service_key, role:, allowed_objects:, audit_context:
)
Constant Summary collapse
- TOOL_CONFIG =
Tool config.
{ 'app_db' => { prefix: 'app_db', label: 'App DB' }, 'postgres_versions' => { prefix: 'versions_db', label: 'Versions DB' } }.freeze
- DESCRIBE_SUMMARY_DESC_LIMIT =
Max length of per-object descriptions returned by the no-args summary
of build_describe_available_data_tool. The full description (and per-column
metadata) is available via theview_name:detail call. Truncating here
keeps the initial schema dump small enough that thinking-capable models
don't burn most of their turn duration just reading it
(see Assistant::ToolLoopGuard::MAX_TURN_DURATION). 80- IDENTIFIER_QUERY_REGEX =
Identifier-shaped query: 0-4 leading uppercase letters followed by 5+ digits
(covers ON5734284, SQ809861, CN25803159, plus bare numerics like 5734284).
When a query matches, run a literalnotes ILIKE '%query%'against
activities first — semantic search of a numeric ID is useless ranking
noise, as the audit of conv 1743 showed (top hit for "5734284" was an
unrelated party at 70% cosine similarity). /\A([A-Z]{0,4})(\d{5,})\z/- LITERAL_FALLBACK_LIMIT =
Maximum literal hits to merge in front of semantic results. We still
run semantic search for context, but exact matches always win the top spots. 5- COMMON_OBJECTS =
The objects Sunny actually asks about, most-used first, measured over the
90 days to 2026-08-12 (ruby_llm_tool_calls). The listing exists to ROUTE
the model to one of these, and the full catalogue is 594 objects — of
which only 159 have ever been described at all, with the top 5 taking 66%
of the calls and the top 10 taking 76%. Sending all 594 every time spent
~7,900 tokens to answer a question the first fifteen almost always
answer.Refresh from telemetry rather than intuition; a name that stops existing
is skipped silently, since the manifest is the source of truth for what
is real. %w[ view_activities view_opportunities view_quotes view_sales_facts quotes parties view_customers items view_orders orders addresses opportunities activities view_line_items view_rmas view_invoices ].freeze
Class Method Summary collapse
-
.activity_search(query:, limit:) ⇒ Array<Hash>
Run an activity search that prefers literal hits for ID-shaped queries and falls back to semantic search for everything else.
-
.literal_activity_search(query:, limit:) ⇒ Array<Hash>
Literal
notes ILIKE '%query%'match for ID-shaped inputs. -
.run_activity_notes_search(query:, limit:) ⇒ String
Logic for the
search_activity_notestool. -
.run_describe_available_data(view_name:, role:, allowed_objects:, party_id:, desc_limit:, domain: nil) ⇒ String
Logic for the
*_describe_available_datatool. - .tools(service_key, role: :employee, allowed_objects: nil, audit_context: {}) ⇒ Object
Class Method Details
.activity_search(query:, limit:) ⇒ Array<Hash>
Run an activity search that prefers literal hits for ID-shaped queries
and falls back to semantic search for everything else.
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 |
# File 'app/services/assistant/postgres_chat_tool_builder.rb', line 49 def self.activity_search(query:, limit:) # Never overshoot the caller's limit. LITERAL_FALLBACK_LIMIT only applies # when the caller's budget is at least that large; smaller budgets cap # literal hits at the budget so the tool's contract holds for narrow # callers like activity_search(query:, limit: 1). literal_budget = [limit, LITERAL_FALLBACK_LIMIT].min literal_hits = literal_activity_search(query: query, limit: literal_budget) semantic_remaining = [limit - literal_hits.size, 0].max semantic_hits = if semantic_remaining.positive? excluded_ids = literal_hits.map { |row| row[:activity].id } ContentEmbedding::ActivityEmbedding .semantic_search(query, limit: semantic_remaining + excluded_ids.size, published_only: false) .filter_map do || activity = . next if activity.nil? || excluded_ids.include?(activity.id) similarity = ((1.0 - (.neighbor_distance / 2.0)) * 100).round(1) { activity: activity, match: 'semantic', similarity: "#{similarity}%" } end.first(semantic_remaining) else [] end literal_hits + semantic_hits end |
.literal_activity_search(query:, limit:) ⇒ Array<Hash>
Literal notes ILIKE '%query%' match for ID-shaped inputs. Used by
activity_search as the first pass for identifier-shaped queries
(ON5734284, 5734284, CN…) before falling back to semantic search.
Returns [] for non-ID-shaped queries so the caller can short-circuit.
253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 |
# File 'app/services/assistant/postgres_chat_tool_builder.rb', line 253 def self.literal_activity_search(query:, limit:) match = query.to_s.strip.match(IDENTIFIER_QUERY_REGEX) return [] unless match _prefix, numeric = match.captures # Search both the prefixed form and the bare numeric form so users can # paste either "ON5734284" or "5734284" without us missing the rows. patterns = [query.to_s.strip, numeric].uniq branches = patterns.map { |p| Activity.where('notes ILIKE ?', "%#{p}%") } Activity.where.any_of(*branches) .order(target_datetime: :desc) .limit(limit) .to_a .map { |activity| { activity: activity, match: 'literal', similarity: '100%' } } rescue StandardError => e Rails.logger.warn("[PostgresChatToolBuilder] literal_activity_search failed: #{e.}") [] end |
.run_activity_notes_search(query:, limit:) ⇒ String
Logic for the search_activity_notes tool. Public so the
RubyLLM::Tool execute body can delegate.
213 214 215 216 217 218 219 220 221 |
# File 'app/services/assistant/postgres_chat_tool_builder.rb', line 213 def self.run_activity_notes_search(query:, limit:) limit = limit.to_i.clamp(1, 50) activities = activity_search(query: query, limit: limit) { query: query, total_results: activities.size, activities: activities.map { |row| serialize_activity_match(row) } }.to_json rescue StandardError => e { error: "Activity search failed: #{e.}" }.to_json end |
.run_describe_available_data(view_name:, role:, allowed_objects:, party_id:, desc_limit:, domain: nil) ⇒ String
Logic for the *_describe_available_data tool. Public so the
RubyLLM::Tool execute body can delegate.
85 86 87 88 89 90 |
# File 'app/services/assistant/postgres_chat_tool_builder.rb', line 85 def self.run_describe_available_data(view_name:, role:, allowed_objects:, party_id:, desc_limit:, domain: nil) allowed = allowed_objects || Assistant::DataPolicy.allowed_objects(role) return describe_object_detail(view_name, allowed: allowed, party_id: party_id) if view_name.present? describe_object_summary(allowed: allowed, desc_limit: desc_limit, domain: domain) end |
.tools(service_key, role: :employee, allowed_objects: nil, audit_context: {}) ⇒ Object
274 275 276 277 278 279 |
# File 'app/services/assistant/postgres_chat_tool_builder.rb', line 274 def tools(service_key, role: :employee, allowed_objects: nil, audit_context: {}) ao_hash = allowed_objects ? Digest::MD5.hexdigest(allowed_objects.to_a.sort.join(',')) : 'nil' cache_key = "#{service_key}:#{role}:#{ao_hash}:#{audit_context[:conversation_id]}:#{audit_context[:user_id]}" @cache ||= {} @cache[cache_key] ||= build_tools(service_key, role: role, allowed_objects: allowed_objects, audit_context: audit_context) end |