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 +4 -4
- data/app/services/your_ai_insight/claude_service.rb +50 -55
- data/lib/your_ai_insight/configuration.rb +24 -30
- data/lib/your_ai_insight/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e5d21f75a48c766bf8a0e2ec1eb1b6f8d7e72d90f156acd8c04f8cf2c693480a
|
|
4
|
+
data.tar.gz: 839c5a52f5406dbb9bfcf8e3e1ac6a682d2d1436fca48de0cf8f473d4863ed96
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
35
|
-
@
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
+
call_api_with_fallback(messages)
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
private
|
|
70
71
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
98
|
-
|
|
92
|
+
raise last_error || ApiError.new("All models exhausted with no response")
|
|
93
|
+
end
|
|
99
94
|
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
# ──
|
|
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
|
-
#
|
|
15
|
-
attr_accessor :openrouter_model
|
|
16
|
-
|
|
17
|
-
attr_accessor :
|
|
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
|
-
# ──
|
|
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
|
-
@
|
|
100
|
-
@
|
|
101
|
-
@
|
|
102
|
-
@
|
|
103
|
-
|
|
104
|
-
@
|
|
105
|
-
@
|
|
106
|
-
@
|
|
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
|
|
140
|
-
@expenses_budget_col
|
|
141
|
-
@expenses_actual_col
|
|
142
|
-
@expenses_job_id_col
|
|
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"
|