your_ai_insight 1.0.11 → 1.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c400f6bb1a051f84900f4ca7c4d229e6f7193aff9bf27b1c6e13772b36935dc6
4
- data.tar.gz: a05ff0274c36660ed5a0e0dfde6ec242e8c293384dc835fd1b02ba0c471e10bd
3
+ metadata.gz: e5d21f75a48c766bf8a0e2ec1eb1b6f8d7e72d90f156acd8c04f8cf2c693480a
4
+ data.tar.gz: 839c5a52f5406dbb9bfcf8e3e1ac6a682d2d1436fca48de0cf8f473d4863ed96
5
5
  SHA512:
6
- metadata.gz: e0893b4723fc2827ea30eabc856f9a48820bc377a286b017be3d78e486ffa218e5930c6f4dfb1fca77fc5616e87038912bd11fee75304638c25b6ebdb9a9d879
7
- data.tar.gz: cc045b166a9f36f017d1f2a90f02412eb213c3d54ee0a739a7958e9a9dd47ec87779a25ac2362c9c460d99178a14cba294a84084efbccd1cc7cd6505f9490dd6
6
+ metadata.gz: f4bc7ce264f285eda38238b4f65cc009dd35475dfaab7c5dcf11a8ed310e81b5a598a73b7da473f17680283a4866c2c484d740ff333bc277112ae2529cf428d6
7
+ data.tar.gz: 52ff32ccb920df4dd04bee5d9a3f638a83b9c4ecae43c835719f6b61876fa007252a79042e689c5429f8efb3a1082e8cb9d11e383644ff0e12e5cc5b76391a95
@@ -4,6 +4,16 @@ module YourAiInsight
4
4
  class ClaudeService
5
5
  include HTTParty
6
6
 
7
+ # Ordered list of free models to try when the primary is rate-limited.
8
+ # OpenRouter routes to whichever provider has capacity.
9
+ FREE_FALLBACK_MODELS = [
10
+ "google/gemini-2.0-flash-exp:free",
11
+ "deepseek/deepseek-chat:free",
12
+ "qwen/qwen-2.5-72b-instruct:free",
13
+ "mistralai/mistral-7b-instruct:free",
14
+ "meta-llama/llama-3.3-70b-instruct:free"
15
+ ].freeze
16
+
7
17
  SYSTEM_PROMPT = <<~PROMPT.freeze
8
18
  You are an expert facility management analyst for AllPro IFM.
9
19
  You have deep knowledge of construction/facility jobs, subcontractor management,
@@ -31,24 +41,15 @@ module YourAiInsight
31
41
  PROMPT
32
42
 
33
43
  def initialize
34
- cfg = YourAiInsight.config
35
- @provider = (cfg.ai_provider || :claude).to_sym
36
- @max_tokens = cfg.max_tokens
37
-
38
- case @provider
39
- when :openrouter
40
- @api_key = cfg.openrouter_api_key ||
41
- ENV["OPENROUTER_API_KEY"] ||
42
- raise(ConfigurationError, "Set config.openrouter_api_key or ENV['OPENROUTER_API_KEY']")
43
- @model = cfg.openrouter_model
44
- @site_url = cfg.openrouter_site_url
45
- @site_name = cfg.openrouter_site_name
46
- else
47
- @api_key = cfg.anthropic_api_key ||
48
- ENV["ANTHROPIC_API_KEY"] ||
49
- raise(ConfigurationError, "Set config.anthropic_api_key or ENV['ANTHROPIC_API_KEY']")
50
- @model = cfg.claude_model
51
- end
44
+ cfg = YourAiInsight.config
45
+ @api_key = cfg.openrouter_api_key ||
46
+ ENV["OPENROUTER_API_KEY"] ||
47
+ raise(ConfigurationError, "Set config.openrouter_api_key or ENV['OPENROUTER_API_KEY']")
48
+ @max_tokens = cfg.max_tokens
49
+ @site_url = cfg.openrouter_site_url
50
+ @site_name = cfg.openrouter_site_name
51
+ @primary_model = cfg.openrouter_model
52
+ @fallback_models = cfg.openrouter_fallback_models || FREE_FALLBACK_MODELS
52
53
  end
53
54
 
54
55
  # Free-form chat with optional live data context injected
@@ -57,51 +58,46 @@ module YourAiInsight
57
58
  role: "user",
58
59
  content: [context_block(context), user_message].reject(&:blank?).join("\n\n")
59
60
  }]
60
- call_api(messages)
61
+ call_api_with_fallback(messages)
61
62
  end
62
63
 
63
64
  # Structured report generation
64
65
  def generate_report(report_type, data)
65
66
  messages = [{ role: "user", content: report_prompt(report_type, data) }]
66
- call_api(messages)
67
+ call_api_with_fallback(messages)
67
68
  end
68
69
 
69
70
  private
70
71
 
71
- def call_api(messages)
72
- @provider == :openrouter ? call_openrouter(messages) : call_claude(messages)
73
- end
74
-
75
- def call_claude(messages)
76
- response = self.class.post(
77
- "https://api.anthropic.com/v1/messages",
78
- headers: {
79
- "x-api-key" => @api_key,
80
- "anthropic-version" => "2023-06-01",
81
- "content-type" => "application/json"
82
- },
83
- body: {
84
- model: @model,
85
- max_tokens: @max_tokens,
86
- system: SYSTEM_PROMPT,
87
- messages: messages
88
- }.to_json,
89
- timeout: 90
90
- )
91
-
92
- body = JSON.parse(response.body)
93
- unless response.success?
94
- raise ApiError, "Claude API error (HTTP #{response.code}): #{body.dig('error', 'message')}"
72
+ # Try primary model first, then each fallback on 429 / provider error
73
+ def call_api_with_fallback(messages)
74
+ models_to_try = ([@primary_model] + @fallback_models).uniq.compact
75
+
76
+ last_error = nil
77
+ models_to_try.each do |model|
78
+ begin
79
+ result = call_api(messages, model)
80
+ return result
81
+ rescue ApiError => e
82
+ last_error = e
83
+ if rate_limited?(e)
84
+ Rails.logger.warn("[YourAiInsight] #{model} rate-limited, trying next model...")
85
+ next
86
+ else
87
+ raise
88
+ end
89
+ end
95
90
  end
96
91
 
97
- body.dig("content", 0, "text").presence ||
98
- raise(ApiError, "Empty response from Claude API")
92
+ raise last_error || ApiError.new("All models exhausted with no response")
93
+ end
99
94
 
100
- rescue HTTParty::Error, Timeout::Error => e
101
- raise ApiError, "Network error reaching Claude API: #{e.message}"
95
+ def rate_limited?(error)
96
+ msg = error.message.to_s.downcase
97
+ msg.include?("429") || msg.include?("rate limit") || msg.include?("provider returned error")
102
98
  end
103
99
 
104
- def call_openrouter(messages)
100
+ def call_api(messages, model)
105
101
  headers = {
106
102
  "Authorization" => "Bearer #{@api_key}",
107
103
  "Content-Type" => "application/json"
@@ -109,14 +105,13 @@ module YourAiInsight
109
105
  headers["HTTP-Referer"] = @site_url if @site_url.present?
110
106
  headers["X-Title"] = @site_name if @site_name.present?
111
107
 
112
- # Prepend system prompt as a system message (OpenAI-compatible format)
113
108
  full_messages = [{ role: "system", content: SYSTEM_PROMPT }] + messages
114
109
 
115
110
  response = self.class.post(
116
111
  "https://openrouter.ai/api/v1/chat/completions",
117
112
  headers: headers,
118
113
  body: {
119
- model: @model,
114
+ model: model,
120
115
  max_tokens: @max_tokens,
121
116
  messages: full_messages
122
117
  }.to_json,
@@ -124,18 +119,18 @@ module YourAiInsight
124
119
  )
125
120
 
126
121
  body = JSON.parse(response.body)
122
+
127
123
  unless response.success?
128
- raise ApiError, "OpenRouter API error (HTTP #{response.code}): #{body.dig('error', 'message')}"
124
+ raise ApiError, "OpenRouter error (HTTP #{response.code}): #{body.dig('error', 'message')}"
129
125
  end
130
126
 
131
127
  body.dig("choices", 0, "message", "content").presence ||
132
- raise(ApiError, "Empty response from OpenRouter API")
128
+ raise(ApiError, "Empty response from OpenRouter (model: #{model})")
133
129
 
134
130
  rescue HTTParty::Error, Timeout::Error => e
135
- raise ApiError, "Network error reaching OpenRouter API: #{e.message}"
131
+ raise ApiError, "Network error reaching OpenRouter: #{e.message}"
136
132
  end
137
133
 
138
- # Injects a live snapshot into the chat prompt
139
134
  def context_block(ctx)
140
135
  return nil if ctx.blank?
141
136
  lines = ["## Live Facility Snapshot"]
@@ -1,22 +1,18 @@
1
1
  module YourAiInsight
2
2
  class Configuration
3
- # ── AI Provider ───────────────────────────────────────────────────────────
4
- # :claude (default) or :openrouter
5
- attr_accessor :ai_provider
6
-
7
- # ── Claude / Anthropic settings ───────────────────────────────────────────
8
- attr_accessor :anthropic_api_key
9
- attr_accessor :claude_model # default: claude-opus-4-5
10
- attr_accessor :max_tokens # default: 2048
11
-
12
- # ── OpenRouter settings ───────────────────────────────────────────────────
3
+ # ── OpenRouter (required) ──────────────────────────────────────────────────
13
4
  attr_accessor :openrouter_api_key
14
- # default: meta-llama/llama-3.3-70b-instruct:free (free, no billing needed)
15
- attr_accessor :openrouter_model
16
- attr_accessor :openrouter_site_url # optional sent as HTTP-Referer
17
- attr_accessor :openrouter_site_name # optional — sent as X-Title
5
+ # See https://openrouter.ai/models?q=free for free models
6
+ attr_accessor :openrouter_model # default: google/gemini-2.0-flash-exp:free
7
+ # Tried in order when the primary model returns 429 / provider error
8
+ attr_accessor :openrouter_fallback_models
9
+ attr_accessor :openrouter_site_url # optional — sent as HTTP-Referer
10
+ attr_accessor :openrouter_site_name # optional — sent as X-Title
18
11
 
19
- # ── Multi-tenancy: scope data to current user's locations/customers ───────
12
+ # ── Response tuning ────────────────────────────────────────────────────────
13
+ attr_accessor :max_tokens # default: 2048
14
+
15
+ # ── Multi-tenancy: scope data to current user's locations/customers ────────
20
16
  # Proc receives current_user and returns an Array of location_ids, or nil for all.
21
17
  # Example: config.location_scope = ->(user) { user.locations.pluck(:id) }
22
18
  attr_accessor :location_scope
@@ -96,16 +92,14 @@ module YourAiInsight
96
92
  attr_accessor :sub_contractors_table # "sub_contractors"
97
93
 
98
94
  def initialize
99
- @ai_provider = :claude
100
- @claude_model = "claude-opus-4-5"
101
- @max_tokens = 2048
102
- @company_name = "AllPro IFM"
103
-
104
- @openrouter_model = "meta-llama/llama-3.3-70b-instruct:free"
105
- @openrouter_site_url = nil
106
- @openrouter_site_name = nil
107
- @logo_url = nil
108
- @default_recipients = []
95
+ @openrouter_model = "google/gemini-2.0-flash-exp:free"
96
+ @openrouter_fallback_models = nil # uses ClaudeService::FREE_FALLBACK_MODELS when nil
97
+ @openrouter_site_url = nil
98
+ @openrouter_site_name = nil
99
+ @max_tokens = 2048
100
+ @company_name = "AllPro IFM"
101
+ @logo_url = nil
102
+ @default_recipients = []
109
103
 
110
104
  # jobs
111
105
  @jobs_table = "jobs"
@@ -136,11 +130,11 @@ module YourAiInsight
136
130
  @bids_task_id_col = "task_id"
137
131
 
138
132
  # expenses
139
- @expenses_table = "expenses"
140
- @expenses_budget_col = "budget"
141
- @expenses_actual_col = "actual"
142
- @expenses_job_id_col = "job_id"
143
- @expenses_task_id_col= "task_id"
133
+ @expenses_table = "expenses"
134
+ @expenses_budget_col = "budget"
135
+ @expenses_actual_col = "actual"
136
+ @expenses_job_id_col = "job_id"
137
+ @expenses_task_id_col = "task_id"
144
138
 
145
139
  # location_budgets
146
140
  @location_budgets_table = "location_budgets"
@@ -1,3 +1,3 @@
1
1
  module YourAiInsight
2
- VERSION = "1.0.11"
2
+ VERSION = "1.0.13"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: your_ai_insight
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.11
4
+ version: 1.0.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - AllPro IFM