Class: Collavre::AiClient
- Inherits:
-
Object
- Object
- Collavre::AiClient
- Defined in:
- app/services/collavre/ai_client.rb
Constant Summary collapse
- SYSTEM_INSTRUCTIONS =
<<~PROMPT.freeze You are a senior expert teammate. Respond: - Be concise and focus on the essentials (avoid unnecessary verbosity). - Use short bullet points only when helpful. - State only what you're confident about; briefly note any uncertainty. - Respond in the asker's language (prefer the latest user message). Keep code and error messages in their original form. PROMPT
- BASE_VENDOR_OPTIONS =
Vendor
[ [ "Google (Gemini)", "google" ], [ "OpenAI", "openai" ], [ "Anthropic", "anthropic" ] ].freeze
Instance Attribute Summary collapse
-
#last_input_tokens ⇒ Object
readonly
Returns the value of attribute last_input_tokens.
-
#last_output_tokens ⇒ Object
readonly
Returns the value of attribute last_output_tokens.
Class Method Summary collapse
- .register_session_vendor(vendor) ⇒ Object
-
.register_vendor_option(label, value) ⇒ Object
Append a vendor
- .registered_vendor_options ⇒ Object
-
.session_vendors ⇒ Object
Vendors that use stateful/incremental sessions.
- .vendor_options ⇒ Object
- .vendor_supports_session?(vendor) ⇒ Boolean
Instance Method Summary collapse
-
#ask(prompt) ⇒ Object
Ask a follow-up question using the existing conversation context.
- #chat(contents, tools: [], &block) ⇒ Object
-
#initialize(vendor:, model:, system_prompt:, llm_api_key: nil, gateway_url: nil, context: {}, log_interactions: true) ⇒ AiClient
constructor
log_interactions: persist each call to ActivityLog.
Constructor Details
#initialize(vendor:, model:, system_prompt:, llm_api_key: nil, gateway_url: nil, context: {}, log_interactions: true) ⇒ AiClient
log_interactions: persist each call to ActivityLog. Default true. Pass false for ephemeral, high-frequency calls on text the user has not submitted (e.g. inline typo correction on debounced typing) so private drafts are never written to server-side activity logs.
60 61 62 63 64 65 66 67 68 69 70 |
# File 'app/services/collavre/ai_client.rb', line 60 def initialize(vendor:, model:, system_prompt:, llm_api_key: nil, gateway_url: nil, context: {}, log_interactions: true) @vendor = vendor @model = model @system_prompt = system_prompt @llm_api_key = llm_api_key @gateway_url = gateway_url @context = context @log_interactions = log_interactions @last_input_tokens = 0 @last_output_tokens = 0 end |
Instance Attribute Details
#last_input_tokens ⇒ Object (readonly)
Returns the value of attribute last_input_tokens.
11 12 13 |
# File 'app/services/collavre/ai_client.rb', line 11 def last_input_tokens @last_input_tokens end |
#last_output_tokens ⇒ Object (readonly)
Returns the value of attribute last_output_tokens.
11 12 13 |
# File 'app/services/collavre/ai_client.rb', line 11 def last_output_tokens @last_output_tokens end |
Class Method Details
.register_session_vendor(vendor) ⇒ Object
47 48 49 |
# File 'app/services/collavre/ai_client.rb', line 47 def register_session_vendor(vendor) session_vendors << vendor.to_s.downcase end |
.register_vendor_option(label, value) ⇒ Object
Append a vendor
28 29 30 31 32 33 34 |
# File 'app/services/collavre/ai_client.rb', line 28 def register_vendor_option(label, value) value = value.to_s return if BASE_VENDOR_OPTIONS.any? { |_l, v| v == value } return if .any? { |_l, v| v == value } << [ label, value ] end |
.registered_vendor_options ⇒ Object
23 24 25 |
# File 'app/services/collavre/ai_client.rb', line 23 def @registered_vendor_options ||= [] end |
.session_vendors ⇒ Object
Vendors that use stateful/incremental sessions. Base providers are stateless; vendor engines that add session support register here so core never string-matches a vendor name to decide session behavior.
43 44 45 |
# File 'app/services/collavre/ai_client.rb', line 43 def session_vendors @session_vendors ||= Set.new end |
.vendor_options ⇒ Object
36 37 38 |
# File 'app/services/collavre/ai_client.rb', line 36 def BASE_VENDOR_OPTIONS + end |
.vendor_supports_session?(vendor) ⇒ Boolean
51 52 53 |
# File 'app/services/collavre/ai_client.rb', line 51 def vendor_supports_session?(vendor) session_vendors.include?(vendor.to_s.downcase) end |
Instance Method Details
#ask(prompt) ⇒ Object
Ask a follow-up question using the existing conversation context. Used to generate approval summaries with full conversation history. Returns the response content string, or nil on failure.
152 153 154 155 156 157 158 159 160 161 162 |
# File 'app/services/collavre/ai_client.rb', line 152 def ask(prompt) return nil unless @conversation # Disable tool calls for summary generation to avoid recursive approval @conversation.with_tools(replace: true) response = @conversation.ask(prompt) response&.content&.strip.presence rescue StandardError => e Rails.logger.warn("AiClient#ask failed: #{e.class} #{e.}") nil end |
#chat(contents, tools: [], &block) ⇒ Object
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 |
# File 'app/services/collavre/ai_client.rb', line 72 def chat(contents, tools: [], &block) response_content = +"" = nil input_tokens = nil output_tokens = nil normalized_vendor = vendor.to_s.downcase unless VENDOR_TO_PROVIDER.key?(normalized_vendor) Rails.logger.warn "Unsupported LLM vendor '#{@vendor}'. Attempting to use default (google)." end @conversation = build_conversation(tools) (@conversation, contents) response = @conversation.complete do |chunk| delta = extract_chunk_content(chunk).to_s # Deliberately NOT `blank?`. A delta of exactly "\n\n" — the paragraph # break, which providers routinely emit as a token of its own — is # blank? == true, so skipping blanks deletes it. And the deleted delta is # never yielded, so it is gone from the caller's stream too: AiAgentService # persists ResponseStreamer#content, which is built only from the yielded # deltas, and the reply lands in the database with its paragraphs glued # together. Only truly empty deltas (role-only / tool-call chunks carry no # content) are skippable. next if delta.empty? response_content << delta yield delta if block_given? end if response response_content = response.content.to_s if response.content.present? # Extract token usage directly from response object (RubyLLM style) if response.respond_to?(:input_tokens) input_tokens = response.input_tokens end if response.respond_to?(:output_tokens) output_tokens = response.output_tokens end end response_content.presence rescue ApprovalPendingError # Preserve conversation for follow-up (e.g. generating approval summary) raise rescue CancelledError raise # Re-raise cancellation errors without catching them rescue StandardError => e = "[#{e.class.name}] #{e.}" # When log_interactions is false (inline typo correction runs on the user's # *unsubmitted* draft), the LLM error message can echo the request text. Log # only the error class to app logs so private drafts never leak — matching the # no-log guarantee already enforced on the parse path (TypoCorrector) and the # ActivityLog gate below. error_message stays intact for the gated ensure log # and the streamed yield (which goes back to the same user). Rails.logger.error "AI Client error: #{@log_interactions ? : "[#{e.class.name}]"}" Rails.logger.error "Partial response length: #{response_content.length} chars" if response_content.present? Rails.logger.debug e.backtrace.join("\n") yield "\n\n⚠️ AI Error: #{}" if block_given? nil ensure @last_input_tokens = input_tokens || 0 @last_output_tokens = output_tokens || 0 if @log_interactions log_interaction( messages: @conversation&.&.to_a || Array(contents), tools: @conversation&.tools&.to_a || [], response_content: response_content.presence, error_message: , input_tokens: input_tokens, output_tokens: output_tokens ) end end |