your_ai_insight 1.0.9 → 1.0.11

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: d87c237089e57905bc9d515c0794f85aa8e398bb37d45395fe20b7350193f51d
4
- data.tar.gz: 52567467c46c142f6e5e23e27473a0c5dc67679130863513fdff295b77b6fbed
3
+ metadata.gz: c400f6bb1a051f84900f4ca7c4d229e6f7193aff9bf27b1c6e13772b36935dc6
4
+ data.tar.gz: a05ff0274c36660ed5a0e0dfde6ec242e8c293384dc835fd1b02ba0c471e10bd
5
5
  SHA512:
6
- metadata.gz: a5b17824ec83493d6323b9afba879fd95184ceaec6a4928183ff87fe12f42cbea21deec59b5433d5bd360fc0940a502378047b79c95d1eb08bc276637fc94378
7
- data.tar.gz: f0fc882bd61d7741b3bc1bbda2504ee975a4bb5e5bd3a207a3af50c6c09f50d7be88060277d1e702d7056efec93fa68f499bcbc3a51b46db11495307b775e531
6
+ metadata.gz: e0893b4723fc2827ea30eabc856f9a48820bc377a286b017be3d78e486ffa218e5930c6f4dfb1fca77fc5616e87038912bd11fee75304638c25b6ebdb9a9d879
7
+ data.tar.gz: cc045b166a9f36f017d1f2a90f02412eb213c3d54ee0a739a7958e9a9dd47ec87779a25ac2362c9c460d99178a14cba294a84084efbccd1cc7cd6505f9490dd6
@@ -3,7 +3,6 @@ require "httparty"
3
3
  module YourAiInsight
4
4
  class ClaudeService
5
5
  include HTTParty
6
- base_uri "https://api.anthropic.com"
7
6
 
8
7
  SYSTEM_PROMPT = <<~PROMPT.freeze
9
8
  You are an expert facility management analyst for AllPro IFM.
@@ -32,11 +31,24 @@ module YourAiInsight
32
31
  PROMPT
33
32
 
34
33
  def initialize
35
- @api_key = YourAiInsight.config.anthropic_api_key ||
36
- ENV["ANTHROPIC_API_KEY"] ||
37
- raise(ConfigurationError, "Set YourAiInsight.configure { |c| c.anthropic_api_key = ENV['ANTHROPIC_API_KEY'] }")
38
- @model = YourAiInsight.config.claude_model
39
- @max_tokens = YourAiInsight.config.max_tokens
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
40
52
  end
41
53
 
42
54
  # Free-form chat with optional live data context injected
@@ -57,8 +69,12 @@ module YourAiInsight
57
69
  private
58
70
 
59
71
  def call_api(messages)
72
+ @provider == :openrouter ? call_openrouter(messages) : call_claude(messages)
73
+ end
74
+
75
+ def call_claude(messages)
60
76
  response = self.class.post(
61
- "/v1/messages",
77
+ "https://api.anthropic.com/v1/messages",
62
78
  headers: {
63
79
  "x-api-key" => @api_key,
64
80
  "anthropic-version" => "2023-06-01",
@@ -74,7 +90,6 @@ module YourAiInsight
74
90
  )
75
91
 
76
92
  body = JSON.parse(response.body)
77
-
78
93
  unless response.success?
79
94
  raise ApiError, "Claude API error (HTTP #{response.code}): #{body.dig('error', 'message')}"
80
95
  end
@@ -86,6 +101,40 @@ module YourAiInsight
86
101
  raise ApiError, "Network error reaching Claude API: #{e.message}"
87
102
  end
88
103
 
104
+ def call_openrouter(messages)
105
+ headers = {
106
+ "Authorization" => "Bearer #{@api_key}",
107
+ "Content-Type" => "application/json"
108
+ }
109
+ headers["HTTP-Referer"] = @site_url if @site_url.present?
110
+ headers["X-Title"] = @site_name if @site_name.present?
111
+
112
+ # Prepend system prompt as a system message (OpenAI-compatible format)
113
+ full_messages = [{ role: "system", content: SYSTEM_PROMPT }] + messages
114
+
115
+ response = self.class.post(
116
+ "https://openrouter.ai/api/v1/chat/completions",
117
+ headers: headers,
118
+ body: {
119
+ model: @model,
120
+ max_tokens: @max_tokens,
121
+ messages: full_messages
122
+ }.to_json,
123
+ timeout: 90
124
+ )
125
+
126
+ body = JSON.parse(response.body)
127
+ unless response.success?
128
+ raise ApiError, "OpenRouter API error (HTTP #{response.code}): #{body.dig('error', 'message')}"
129
+ end
130
+
131
+ body.dig("choices", 0, "message", "content").presence ||
132
+ raise(ApiError, "Empty response from OpenRouter API")
133
+
134
+ rescue HTTParty::Error, Timeout::Error => e
135
+ raise ApiError, "Network error reaching OpenRouter API: #{e.message}"
136
+ end
137
+
89
138
  # Injects a live snapshot into the chat prompt
90
139
  def context_block(ctx)
91
140
  return nil if ctx.blank?
@@ -80,15 +80,17 @@ module YourAiInsight
80
80
  end
81
81
 
82
82
  def jobs_by_status
83
- rows = exec_query("SELECT #{@c.jobs_status_col}, COUNT(*) as cnt FROM #{j} #{job_where} GROUP BY #{@c.jobs_status_col}")
83
+ col = @c.jobs_status_col
84
+ rows = exec_query("SELECT #{col}, COUNT(*) as cnt FROM #{j} #{job_where} GROUP BY #{col}")
84
85
  labels = { 0 => "new", 1 => "assigned", 2 => "active", 3 => "work_complete", 4 => "all_tasks_complete", 5 => "finaled" }
85
- rows.each_with_object({}) { |r, h| h[labels[r["status"].to_i] || r["status"].to_s] = r["cnt"].to_i }
86
+ rows.each_with_object({}) { |r, h| h[labels[r[col].to_i] || r[col].to_s] = r["cnt"].to_i }
86
87
  rescue StandardError; {}
87
88
  end
88
89
 
89
90
  def jobs_by_priority
90
- rows = exec_query("SELECT #{@c.jobs_priority_col}, COUNT(*) as cnt FROM #{j} #{job_where} AND #{@c.jobs_priority_col} IS NOT NULL GROUP BY #{@c.jobs_priority_col}")
91
- rows.each_with_object({}) { |r, h| h[r["priority"]] = r["cnt"].to_i }
91
+ col = @c.jobs_priority_col
92
+ rows = exec_query("SELECT #{col}, COUNT(*) as cnt FROM #{j} #{job_where} AND #{col} IS NOT NULL GROUP BY #{col}")
93
+ rows.each_with_object({}) { |r, h| h[r[col]] = r["cnt"].to_i }
92
94
  rescue StandardError; {}
93
95
  end
94
96
 
@@ -103,28 +105,28 @@ module YourAiInsight
103
105
 
104
106
  def jobs_on_hold_detail
105
107
  exec_query(<<~SQL)
106
- SELECT j.id, j.name, j.#{@c.jobs_priority_col}, j.#{@c.jobs_status_col},
107
- j.created_at, l.name as location_name, c.name as customer_name
108
+ SELECT #{j}.id, #{j}.name, #{j}.#{@c.jobs_priority_col}, #{j}.#{@c.jobs_status_col},
109
+ #{j}.created_at, l.name as location_name, c.name as customer_name
108
110
  FROM #{j}
109
- LEFT JOIN #{loc} l ON l.id = j.#{@c.jobs_location_id_col}
110
- LEFT JOIN #{cust} c ON c.id = j.#{@c.jobs_customer_id_col}
111
- #{job_where} AND j.#{@c.jobs_hold_col} = true
112
- ORDER BY j.created_at ASC LIMIT 25
111
+ LEFT JOIN #{loc} l ON l.id = #{j}.#{@c.jobs_location_id_col}
112
+ LEFT JOIN #{cust} c ON c.id = #{j}.#{@c.jobs_customer_id_col}
113
+ #{job_where} AND #{j}.#{@c.jobs_hold_col} = true
114
+ ORDER BY #{j}.created_at ASC LIMIT 25
113
115
  SQL
114
116
  rescue StandardError; []
115
117
  end
116
118
 
117
119
  def over_budget_jobs_detail
118
120
  exec_query(<<~SQL)
119
- SELECT j.id, j.name,
121
+ SELECT #{j}.id, #{j}.name,
120
122
  COALESCE(SUM(e.#{@c.expenses_actual_col}),0) as total_actual,
121
123
  COALESCE(SUM(e.#{@c.expenses_budget_col}),0) as total_budget,
122
124
  l.name as location_name
123
125
  FROM #{j}
124
- LEFT JOIN #{exp} e ON e.#{@c.expenses_job_id_col} = j.id
125
- LEFT JOIN #{loc} l ON l.id = j.#{@c.jobs_location_id_col}
126
+ LEFT JOIN #{exp} e ON e.#{@c.expenses_job_id_col} = #{j}.id
127
+ LEFT JOIN #{loc} l ON l.id = #{j}.#{@c.jobs_location_id_col}
126
128
  #{job_where}
127
- GROUP BY j.id, j.name, l.name
129
+ GROUP BY #{j}.id, #{j}.name, l.name
128
130
  HAVING COALESCE(SUM(e.#{@c.expenses_actual_col}),0) > COALESCE(SUM(e.#{@c.expenses_budget_col}),0)
129
131
  ORDER BY (COALESCE(SUM(e.#{@c.expenses_actual_col}),0) - COALESCE(SUM(e.#{@c.expenses_budget_col}),0)) DESC
130
132
  LIMIT 15
@@ -140,7 +142,7 @@ module YourAiInsight
140
142
  exec_query(<<~SQL)
141
143
  SELECT l.name, COUNT(j.id) as job_count
142
144
  FROM #{loc} l
143
- LEFT JOIN #{j} ON j.#{@c.jobs_location_id_col} = l.id
145
+ LEFT JOIN #{j} j ON j.#{@c.jobs_location_id_col} = l.id
144
146
  #{location_join_where("l")}
145
147
  GROUP BY l.name ORDER BY job_count DESC LIMIT 10
146
148
  SQL
@@ -151,7 +153,7 @@ module YourAiInsight
151
153
  exec_query(<<~SQL)
152
154
  SELECT c.name, COUNT(j.id) as job_count
153
155
  FROM #{cust} c
154
- LEFT JOIN #{j} ON j.#{@c.jobs_customer_id_col} = c.id
156
+ LEFT JOIN #{j} j ON j.#{@c.jobs_customer_id_col} = c.id
155
157
  #{customer_join_where("c")}
156
158
  GROUP BY c.name ORDER BY job_count DESC LIMIT 10
157
159
  SQL
@@ -191,15 +193,15 @@ module YourAiInsight
191
193
 
192
194
  def overdue_tasks_detail
193
195
  exec_query(<<~SQL)
194
- SELECT t.id, t.name, t.#{@c.tasks_complete_col}, t.#{@c.tasks_budget_col},
195
- t.#{@c.tasks_actual_col}, j.name as job_name, l.name as location_name
196
+ SELECT #{t}.id, #{t}.name, #{t}.#{@c.tasks_complete_col}, #{t}.#{@c.tasks_budget_col},
197
+ #{t}.#{@c.tasks_actual_col}, j.name as job_name, l.name as location_name
196
198
  FROM #{t}
197
- LEFT JOIN #{j} ON j.id = t.#{@c.tasks_job_id_col}
199
+ LEFT JOIN #{j} j ON j.id = #{t}.#{@c.tasks_job_id_col}
198
200
  LEFT JOIN #{loc} l ON l.id = j.#{@c.jobs_location_id_col}
199
201
  #{task_where}
200
- AND t.#{@c.tasks_complete_col} < CURRENT_DATE
201
- AND t.#{@c.tasks_status_col} != 2
202
- ORDER BY t.#{@c.tasks_complete_col} ASC LIMIT 20
202
+ AND #{t}.#{@c.tasks_complete_col} < CURRENT_DATE
203
+ AND #{t}.#{@c.tasks_status_col} != 2
204
+ ORDER BY #{t}.#{@c.tasks_complete_col} ASC LIMIT 20
203
205
  SQL
204
206
  rescue StandardError; []
205
207
  end
@@ -227,9 +229,9 @@ module YourAiInsight
227
229
  exec_query(<<~SQL)
228
230
  SELECT b.id, b.#{@c.bids_amount_col}, b.received, b.notes,
229
231
  t.name as task_name, j.name as job_name, v.name as vendor_name
230
- FROM #{b}
231
- LEFT JOIN #{t} ON #{t}.id = b.#{@c.bids_task_id_col}
232
- LEFT JOIN #{j} ON #{j}.id = #{t}.#{@c.tasks_job_id_col}
232
+ FROM #{b} b
233
+ LEFT JOIN #{t} t ON t.id = b.#{@c.bids_task_id_col}
234
+ LEFT JOIN #{j} j ON j.id = t.#{@c.tasks_job_id_col}
233
235
  LEFT JOIN vendors v ON v.id = b.vendor_id
234
236
  WHERE (b.#{@c.bids_status_col} IS NULL OR b.#{@c.bids_status_col} NOT IN ('accepted','rejected'))
235
237
  AND b.created_at BETWEEN #{range_sql}
@@ -365,7 +367,7 @@ module YourAiInsight
365
367
  def br = @c.budget_requests_table
366
368
 
367
369
  def range_sql
368
- "'#{@date_range.first.to_s(:db)}' AND '#{@date_range.last.to_s(:db)}'"
370
+ "'#{@date_range.first.strftime('%Y-%m-%d %H:%M:%S')}' AND '#{@date_range.last.strftime('%Y-%m-%d %H:%M:%S')}'"
369
371
  end
370
372
 
371
373
  def range_label
@@ -1,12 +1,21 @@
1
1
  module YourAiInsight
2
2
  class Configuration
3
- # ── Required ──────────────────────────────────────────────────────────────
4
- attr_accessor :anthropic_api_key
3
+ # ── AI Provider ───────────────────────────────────────────────────────────
4
+ # :claude (default) or :openrouter
5
+ attr_accessor :ai_provider
5
6
 
6
- # ── Model / Claude settings ───────────────────────────────────────────────
7
- attr_accessor :claude_model # default: claude-sonnet-4-20250514
7
+ # ── Claude / Anthropic settings ───────────────────────────────────────────
8
+ attr_accessor :anthropic_api_key
9
+ attr_accessor :claude_model # default: claude-opus-4-5
8
10
  attr_accessor :max_tokens # default: 2048
9
11
 
12
+ # ── OpenRouter settings ───────────────────────────────────────────────────
13
+ 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
18
+
10
19
  # ── Multi-tenancy: scope data to current user's locations/customers ───────
11
20
  # Proc receives current_user and returns an Array of location_ids, or nil for all.
12
21
  # Example: config.location_scope = ->(user) { user.locations.pluck(:id) }
@@ -87,9 +96,14 @@ module YourAiInsight
87
96
  attr_accessor :sub_contractors_table # "sub_contractors"
88
97
 
89
98
  def initialize
90
- @claude_model = "claude-sonnet-4-20250514"
99
+ @ai_provider = :claude
100
+ @claude_model = "claude-opus-4-5"
91
101
  @max_tokens = 2048
92
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
93
107
  @logo_url = nil
94
108
  @default_recipients = []
95
109
 
@@ -1,3 +1,3 @@
1
1
  module YourAiInsight
2
- VERSION = "1.0.9"
2
+ VERSION = "1.0.11"
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.9
4
+ version: 1.0.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - AllPro IFM