your_ai_insight 1.0.7
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +284 -0
- data/app/controllers/your_ai_insight/application_controller.rb +35 -0
- data/app/controllers/your_ai_insight/chats_controller.rb +26 -0
- data/app/controllers/your_ai_insight/dashboard_controller.rb +25 -0
- data/app/controllers/your_ai_insight/reports_controller.rb +64 -0
- data/app/jobs/your_ai_insight/scheduled_report_job.rb +98 -0
- data/app/mailers/your_ai_insight/ai_report_mailer.rb +80 -0
- data/app/models/your_ai_insight/ai_report.rb +24 -0
- data/app/services/your_ai_insight/claude_service.rb +175 -0
- data/app/services/your_ai_insight/facility_data_service.rb +449 -0
- data/app/views/your_ai_insight/chat/_widget.html.erb +153 -0
- data/app/views/your_ai_insight/dashboard/index.html.erb +818 -0
- data/config/routes.rb +14 -0
- data/db/migrate/20240101000001_create_your_ai_insight_reports.rb +20 -0
- data/lib/tasks/your_ai_insight.rake +63 -0
- data/lib/tasks/your_ai_insight_seed.rake +299 -0
- data/lib/your_ai_insight/configuration.rb +168 -0
- data/lib/your_ai_insight/engine.rb +17 -0
- data/lib/your_ai_insight/version.rb +3 -0
- data/lib/your_ai_insight.rb +3 -0
- metadata +132 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1165bce9fe9219ffffaf726123fb6f47c403c9227706c46b55a0b33cca0f11cf
|
|
4
|
+
data.tar.gz: d7461640435eabdae0ac5ed47c217d8c9f6aac22532e96a3316c36806198299d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 70a6dd041f904dee49762ec1a5f26d09bf3f55daa620fa510e89a0deb69743dad752d20c769b15160795006e43c013692e531f07d6407f55095645cf00a673c6
|
|
7
|
+
data.tar.gz: 937bac54feed81fbe9f66da49ec1b9fec4b7ca6543ffcd2056e05d3dfb908b97be230ee76cb0093fb71f7c1da8c8a5f34ae225476d219179c4c32c9be1c08de6
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 AllPro IFM
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# YourAiInsight — Rails Engine
|
|
2
|
+
|
|
3
|
+
> AI-powered **Summary Dashboards**, **Compliance & Audit Reports**, and a **live chat assistant** for facility management portals.
|
|
4
|
+
> Built for `portal.allproifm.com` and any Rails 6+ facility management app with a compatible schema.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## What it analyses (from your real schema)
|
|
9
|
+
|
|
10
|
+
| Data Source | Table | What AI uses |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| Jobs | `jobs` | status, priority, hold, sales_price, not_to_exceed, accepted/finaled dates |
|
|
13
|
+
| Tasks | `tasks` | status, budget, actual, start/complete dates |
|
|
14
|
+
| Bids | `bids` | status, amount, actual, active flag |
|
|
15
|
+
| Expenses | `expenses` | budget vs actual, expense_type |
|
|
16
|
+
| Location Budgets | `location_budgets` | month1–month12 per location/fiscal_year |
|
|
17
|
+
| Budget Requests | `budget_requests` | status (0=pending,1=approved,2=rejected), amount |
|
|
18
|
+
| Pay Requests | `pay_requests` | paid/declined/outstanding status |
|
|
19
|
+
| Locations | `locations` | name, customer scoping |
|
|
20
|
+
| Customers | `customers` | name, status |
|
|
21
|
+
| Vendors | `vendors` | linked via tasks & bids |
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
### 1. Add the gem
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
# Gemfile
|
|
31
|
+
gem "your_ai_insight"
|
|
32
|
+
|
|
33
|
+
# During development (local path):
|
|
34
|
+
# gem "your_ai_insight", path: "../your_ai_insight"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
bundle install
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 2. Run the migration
|
|
42
|
+
|
|
43
|
+
This only creates the engine's own `your_ai_insight_reports` table.
|
|
44
|
+
**Your existing tables are never modified.**
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
rails your_ai_insight:install:migrations
|
|
48
|
+
rails db:migrate
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 3. Mount the engine
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
# config/routes.rb
|
|
55
|
+
Rails.application.routes.draw do
|
|
56
|
+
mount YourAiInsight::Engine => "/your_ai_insight"
|
|
57
|
+
# ... your existing routes
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 4. Configure
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
# config/initializers/your_ai_insight.rb
|
|
65
|
+
YourAiInsight.configure do |config|
|
|
66
|
+
# Required
|
|
67
|
+
config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
|
|
68
|
+
|
|
69
|
+
# Scope data to the current user's locations (recommended for multi-tenant)
|
|
70
|
+
# The proc receives current_user and returns an array of location IDs (or nil for all)
|
|
71
|
+
config.location_scope = ->(user) {
|
|
72
|
+
user.locations.pluck(:id) # if user has_many :locations via location_users
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Or scope by customer instead:
|
|
76
|
+
# config.customer_scope = ->(user) { [user.customer_id] }
|
|
77
|
+
|
|
78
|
+
# Branding (used in emails and chat widget)
|
|
79
|
+
config.company_name = "AllPro IFM"
|
|
80
|
+
config.logo_url = "https://portal.allproifm.com/logo.png" # optional
|
|
81
|
+
|
|
82
|
+
# Default email recipients for scheduled reports
|
|
83
|
+
config.default_recipients = ["ops@allproifm.com"]
|
|
84
|
+
|
|
85
|
+
# ── Schema overrides ──────────────────────────────────────────────────────
|
|
86
|
+
# Only needed if you install this in a different app with different column names.
|
|
87
|
+
# The defaults below match portal.allproifm.com schema.rb exactly.
|
|
88
|
+
#
|
|
89
|
+
# config.jobs_table = "jobs"
|
|
90
|
+
# config.jobs_status_col = "status"
|
|
91
|
+
# config.jobs_priority_col = "priority"
|
|
92
|
+
# config.jobs_sales_price_col = "sales_price"
|
|
93
|
+
# config.jobs_not_to_exceed_col = "not_to_exceed"
|
|
94
|
+
# config.jobs_location_id_col = "location_id"
|
|
95
|
+
# config.jobs_customer_id_col = "customer_id"
|
|
96
|
+
# config.jobs_accepted_dt_col = "accepted_dt"
|
|
97
|
+
# config.jobs_finaled_dt_col = "finaled_dt"
|
|
98
|
+
# config.jobs_hold_col = "hold"
|
|
99
|
+
# config.tasks_table = "tasks"
|
|
100
|
+
# config.tasks_status_col = "status"
|
|
101
|
+
# config.tasks_budget_col = "budget"
|
|
102
|
+
# config.tasks_actual_col = "actual"
|
|
103
|
+
# config.tasks_job_id_col = "job_id"
|
|
104
|
+
# config.tasks_complete_col = "complete"
|
|
105
|
+
# config.bids_table = "bids"
|
|
106
|
+
# config.bids_status_col = "status"
|
|
107
|
+
# config.bids_amount_col = "amount"
|
|
108
|
+
# config.bids_actual_col = "actual"
|
|
109
|
+
# config.expenses_table = "expenses"
|
|
110
|
+
# config.expenses_budget_col = "budget"
|
|
111
|
+
# config.expenses_actual_col = "actual"
|
|
112
|
+
# config.location_budgets_table = "location_budgets"
|
|
113
|
+
# config.budget_requests_table = "budget_requests"
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### 5. Add the chat widget
|
|
118
|
+
|
|
119
|
+
In `app/views/layouts/application.html.erb`, just before `</body>`:
|
|
120
|
+
|
|
121
|
+
```erb
|
|
122
|
+
<%= render "your_ai_insight/chat/widget" %>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
A floating **💡 AI Facility Assistant** button appears bottom-right. ✅
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Generating Reports
|
|
130
|
+
|
|
131
|
+
### Via the API (JSON)
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# Summary dashboard — last 30 days
|
|
135
|
+
curl -X POST https://portal.allproifm.com/your_ai_insight/reports \
|
|
136
|
+
-H "Content-Type: application/json" \
|
|
137
|
+
-H "X-CSRF-Token: ..." \
|
|
138
|
+
-d '{"report_type":"summary_dashboard","start_date":"2024-01-01","end_date":"2024-01-31"}'
|
|
139
|
+
|
|
140
|
+
# Compliance & audit report
|
|
141
|
+
curl -X POST https://portal.allproifm.com/your_ai_insight/reports \
|
|
142
|
+
-H "Content-Type: application/json" \
|
|
143
|
+
-d '{"report_type":"compliance_audit"}'
|
|
144
|
+
|
|
145
|
+
# List recent reports
|
|
146
|
+
curl https://portal.allproifm.com/your_ai_insight/reports
|
|
147
|
+
|
|
148
|
+
# Fetch a specific report
|
|
149
|
+
curl https://portal.allproifm.com/your_ai_insight/reports/42
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Via Rake Tasks
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
# Summary dashboard — last 7 days — email two people
|
|
156
|
+
rake "your_ai_insight:summary_dashboard[ops@allproifm.com+mgr@allproifm.com,7]"
|
|
157
|
+
|
|
158
|
+
# Compliance audit — last 30 days
|
|
159
|
+
rake "your_ai_insight:compliance_audit[ops@allproifm.com,30]"
|
|
160
|
+
|
|
161
|
+
# Test both reports immediately
|
|
162
|
+
REPORT_EMAILS=you@allproifm.com rake your_ai_insight:run_now
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Scheduling with Delayed::Job
|
|
168
|
+
|
|
169
|
+
### Option A — delayed_cron_job gem (recommended)
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
# config/initializers/your_ai_insight_schedule.rb
|
|
173
|
+
# Runs once on boot — Delayed::Job deduplicates by cron expression.
|
|
174
|
+
|
|
175
|
+
if defined?(Delayed::Job)
|
|
176
|
+
# Weekly summary dashboard — every Monday 8am
|
|
177
|
+
YourAiInsight::ScheduledReportJob.new(
|
|
178
|
+
report_type: "summary_dashboard",
|
|
179
|
+
recipient_emails: ["ops@allproifm.com", "exec@allproifm.com"],
|
|
180
|
+
days_back: 7
|
|
181
|
+
).enqueue(cron: "0 8 * * 1")
|
|
182
|
+
|
|
183
|
+
# Monthly compliance audit — 1st of month 9am
|
|
184
|
+
YourAiInsight::ScheduledReportJob.new(
|
|
185
|
+
report_type: "compliance_audit",
|
|
186
|
+
recipient_emails: User.where(admin: true).pluck(:email),
|
|
187
|
+
days_back: 30
|
|
188
|
+
).enqueue(cron: "0 9 1 * *")
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Option B — whenever gem
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
# config/schedule.rb
|
|
196
|
+
every :monday, at: "8:00 am" do
|
|
197
|
+
rake "your_ai_insight:summary_dashboard[ops@allproifm.com,7]"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
every 1.month, at: "9:00 am" do
|
|
201
|
+
rake "your_ai_insight:compliance_audit[ops@allproifm.com,30]"
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Option C — Direct enqueue from your app
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
YourAiInsight::ScheduledReportJob.new(
|
|
209
|
+
report_type: "compliance_audit",
|
|
210
|
+
location_ids: [1, 2, 3], # scope to specific locations
|
|
211
|
+
recipient_emails: ["ops@allproifm.com"],
|
|
212
|
+
days_back: 30
|
|
213
|
+
).enqueue(run_at: 1.hour.from_now)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Chat — example questions it can answer
|
|
219
|
+
|
|
220
|
+
Using your live `jobs`, `tasks`, `bids`, `expenses`, and `budget_requests` data:
|
|
221
|
+
|
|
222
|
+
- *"How many jobs are currently on hold and why might that be a problem?"*
|
|
223
|
+
- *"What's our budget vs actual across all expenses this month?"*
|
|
224
|
+
- *"Which tasks are overdue and what's the total risk?"*
|
|
225
|
+
- *"How many bids are waiting on a decision?"*
|
|
226
|
+
- *"What's the status of pending budget requests?"*
|
|
227
|
+
- *"Which locations have the most active jobs right now?"*
|
|
228
|
+
- *"Give me a health check on our job pipeline."*
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## Selling to Other Clients
|
|
233
|
+
|
|
234
|
+
This engine is built to be white-labelled:
|
|
235
|
+
|
|
236
|
+
1. **Package** as a private gem on Gemfury, Cloudsmith, or your own gem server
|
|
237
|
+
2. **Scope data** per client using `location_scope` or `customer_scope`
|
|
238
|
+
3. **White-label** by setting `company_name` and `logo_url` per client
|
|
239
|
+
4. **License** clients via their own `ANTHROPIC_API_KEY`, or proxy yours and charge per report via `AiReport.count`
|
|
240
|
+
|
|
241
|
+
### Pricing ideas
|
|
242
|
+
- Per report ($X/report, track via `YourAiInsight::AiReport.count`)
|
|
243
|
+
- Monthly subscription (unlimited reports)
|
|
244
|
+
- Included in your SaaS tier pricing
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## API Reference
|
|
249
|
+
|
|
250
|
+
| Endpoint | Method | Params | Description |
|
|
251
|
+
|---|---|---|---|
|
|
252
|
+
| `/your_ai_insight/chat` | POST | `{ message }` | Chat with AI about live facility data |
|
|
253
|
+
| `/your_ai_insight/reports` | GET | — | List reports (newest first) |
|
|
254
|
+
| `/your_ai_insight/reports` | POST | `{ report_type, start_date?, end_date? }` | Generate report on-demand |
|
|
255
|
+
| `/your_ai_insight/reports/:id` | GET | — | Fetch a specific report |
|
|
256
|
+
|
|
257
|
+
### Report types
|
|
258
|
+
| `report_type` | Description |
|
|
259
|
+
|---|---|
|
|
260
|
+
| `summary_dashboard` | Executive overview: health score, job pipeline, budget performance, vendor activity, recommendations |
|
|
261
|
+
| `compliance_audit` | Overdue tasks, jobs on hold, pending budget requests, outstanding pay requests, over-budget jobs |
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Requirements
|
|
266
|
+
|
|
267
|
+
- Ruby ≥ 2.6
|
|
268
|
+
- Rails 6.0–7.x
|
|
269
|
+
- PostgreSQL (uses `EXTRACT`, `DATE_TRUNC`, `jsonb`)
|
|
270
|
+
- Delayed::Job (any backend — ActiveRecord recommended)
|
|
271
|
+
- `ANTHROPIC_API_KEY` environment variable
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## Changelog
|
|
276
|
+
|
|
277
|
+
### 1.0.0
|
|
278
|
+
- Initial release
|
|
279
|
+
- Full schema mapping for portal.allproifm.com (jobs, tasks, bids, expenses, location_budgets, budget_requests, pay_requests)
|
|
280
|
+
- Summary Dashboard + Compliance & Audit report types
|
|
281
|
+
- Floating chat widget with quick-prompt chips
|
|
282
|
+
- Delayed::Job integration with cron support
|
|
283
|
+
- Multi-tenant location/customer scoping
|
|
284
|
+
- Branded HTML email reports
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module YourAiInsight
|
|
2
|
+
class ApplicationController < ActionController::Base
|
|
3
|
+
protect_from_forgery with: :exception
|
|
4
|
+
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def current_location_ids
|
|
8
|
+
cfg = YourAiInsight.config
|
|
9
|
+
return nil unless cfg.location_scope && respond_to?(:current_user, true) && current_user
|
|
10
|
+
cfg.location_scope.call(current_user)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def current_customer_ids
|
|
14
|
+
cfg = YourAiInsight.config
|
|
15
|
+
return nil unless cfg.customer_scope && respond_to?(:current_user, true) && current_user
|
|
16
|
+
cfg.customer_scope.call(current_user)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def parse_date_range
|
|
20
|
+
start_d = params[:start_date].present? ? Date.parse(params[:start_date]) : 30.days.ago.to_date
|
|
21
|
+
end_d = params[:end_date].present? ? Date.parse(params[:end_date]) : Date.today
|
|
22
|
+
start_d.beginning_of_day..end_d.end_of_day
|
|
23
|
+
rescue ArgumentError
|
|
24
|
+
30.days.ago.beginning_of_day..Time.current
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def data_service(date_range: parse_date_range)
|
|
28
|
+
FacilityDataService.new(
|
|
29
|
+
location_ids: current_location_ids,
|
|
30
|
+
customer_ids: current_customer_ids,
|
|
31
|
+
date_range: date_range
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
## app/controllers/your_ai_insight/chats_controller.rb
|
|
2
|
+
module YourAiInsight
|
|
3
|
+
class ChatsController < ApplicationController
|
|
4
|
+
|
|
5
|
+
# POST /your_ai_insight/chat
|
|
6
|
+
# Body: { "message": "How many jobs are on hold?" }
|
|
7
|
+
def create
|
|
8
|
+
message = params.require(:message).strip
|
|
9
|
+
return render json: { error: "Message cannot be blank" }, status: :unprocessable_entity if message.blank?
|
|
10
|
+
|
|
11
|
+
context = data_service.quick_context
|
|
12
|
+
response_text = ClaudeService.new.chat(message, context: context)
|
|
13
|
+
|
|
14
|
+
render json: { response: response_text }
|
|
15
|
+
|
|
16
|
+
rescue ActionController::ParameterMissing => e
|
|
17
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
18
|
+
rescue YourAiInsight::ApiError => e
|
|
19
|
+
Rails.logger.error("[YourAiInsight::ChatsController] #{e.message}")
|
|
20
|
+
render json: { error: "AI service temporarily unavailable. Please try again shortly." }, status: :service_unavailable
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
Rails.logger.error("[YourAiInsight::ChatsController] #{e.class}: #{e.message}")
|
|
23
|
+
render json: { error: "An unexpected error occurred." }, status: :internal_server_error
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module YourAiInsight
|
|
2
|
+
class DashboardController < ApplicationController
|
|
3
|
+
|
|
4
|
+
# GET /your_ai_insight/dashboard
|
|
5
|
+
def index
|
|
6
|
+
@recent_reports = AiReport.recent.limit(10)
|
|
7
|
+
@report_counts = {
|
|
8
|
+
total: AiReport.count,
|
|
9
|
+
dashboards: AiReport.dashboards.count,
|
|
10
|
+
compliance: AiReport.compliance.count,
|
|
11
|
+
scheduled: AiReport.scheduled.count
|
|
12
|
+
}
|
|
13
|
+
@quick_context = data_service.quick_context rescue {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# GET /your_ai_insight/dashboard/quick_context (AJAX – live stats panel)
|
|
17
|
+
def quick_context
|
|
18
|
+
ctx = data_service.quick_context
|
|
19
|
+
render json: ctx
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
render json: { error: e.message }, status: :service_unavailable
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module YourAiInsight
|
|
2
|
+
class ReportsController < ApplicationController
|
|
3
|
+
VALID_TYPES = %w[summary_dashboard compliance_audit].freeze
|
|
4
|
+
|
|
5
|
+
# GET /your_ai_insight/reports
|
|
6
|
+
def index
|
|
7
|
+
reports = AiReport.order(created_at: :desc).limit(50)
|
|
8
|
+
render json: reports.map { |r|
|
|
9
|
+
{ id: r.id, report_type: r.report_type, scheduled: r.scheduled,
|
|
10
|
+
period_start: r.period_start, period_end: r.period_end, created_at: r.created_at }
|
|
11
|
+
}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# POST /your_ai_insight/reports
|
|
15
|
+
# Body: { "report_type": "summary_dashboard", "start_date": "2024-01-01", "end_date": "2024-01-31" }
|
|
16
|
+
def create
|
|
17
|
+
type = params.require(:report_type)
|
|
18
|
+
return render json: { error: "Invalid report_type. Valid: #{VALID_TYPES.join(', ')}" }, status: :unprocessable_entity \
|
|
19
|
+
unless VALID_TYPES.include?(type)
|
|
20
|
+
|
|
21
|
+
svc = data_service
|
|
22
|
+
data = type == "summary_dashboard" ? svc.summary_dashboard_data : svc.compliance_audit_data
|
|
23
|
+
|
|
24
|
+
content = ClaudeService.new.generate_report(type.to_sym, data)
|
|
25
|
+
dr = parse_date_range
|
|
26
|
+
|
|
27
|
+
report = AiReport.create!(
|
|
28
|
+
report_type: type,
|
|
29
|
+
content: content,
|
|
30
|
+
raw_data: data,
|
|
31
|
+
period_start: dr.first.to_date,
|
|
32
|
+
period_end: dr.last.to_date,
|
|
33
|
+
scheduled: false
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
render json: { id: report.id, content: content }, status: :created
|
|
37
|
+
|
|
38
|
+
rescue ActionController::ParameterMissing => e
|
|
39
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
40
|
+
rescue YourAiInsight::ApiError => e
|
|
41
|
+
Rails.logger.error("[YourAiInsight::ReportsController] #{e.message}")
|
|
42
|
+
render json: { error: "AI service temporarily unavailable." }, status: :service_unavailable
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
Rails.logger.error("[YourAiInsight::ReportsController] #{e.class}: #{e.message}")
|
|
45
|
+
render json: { error: "Report generation failed." }, status: :internal_server_error
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# GET /your_ai_insight/reports/:id
|
|
49
|
+
def show
|
|
50
|
+
report = AiReport.find(params[:id])
|
|
51
|
+
render json: {
|
|
52
|
+
id: report.id,
|
|
53
|
+
report_type: report.report_type,
|
|
54
|
+
content: report.content,
|
|
55
|
+
period_start: report.period_start,
|
|
56
|
+
period_end: report.period_end,
|
|
57
|
+
scheduled: report.scheduled,
|
|
58
|
+
created_at: report.created_at
|
|
59
|
+
}
|
|
60
|
+
rescue ActiveRecord::RecordNotFound
|
|
61
|
+
render json: { error: "Report not found" }, status: :not_found
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
module YourAiInsight
|
|
2
|
+
#
|
|
3
|
+
# Scheduled report job for Delayed::Job.
|
|
4
|
+
#
|
|
5
|
+
# USAGE — enqueue directly:
|
|
6
|
+
#
|
|
7
|
+
# YourAiInsight::ScheduledReportJob.new(
|
|
8
|
+
# report_type: "summary_dashboard",
|
|
9
|
+
# recipient_emails: ["ops@allproifm.com"],
|
|
10
|
+
# days_back: 7
|
|
11
|
+
# ).enqueue
|
|
12
|
+
#
|
|
13
|
+
# USAGE — schedule with delayed_cron_job gem:
|
|
14
|
+
#
|
|
15
|
+
# YourAiInsight::ScheduledReportJob.new(
|
|
16
|
+
# report_type: "compliance_audit",
|
|
17
|
+
# recipient_emails: User.where(admin: true).pluck(:email)
|
|
18
|
+
# ).enqueue(cron: "0 8 1 * *") # 1st of month at 8am
|
|
19
|
+
#
|
|
20
|
+
# USAGE — enqueue from a rake task / whenever:
|
|
21
|
+
#
|
|
22
|
+
# rake "your_ai_insight:compliance_audit[ops@allproifm.com,30]"
|
|
23
|
+
#
|
|
24
|
+
class ScheduledReportJob
|
|
25
|
+
VALID_TYPES = %w[summary_dashboard compliance_audit].freeze
|
|
26
|
+
|
|
27
|
+
attr_reader :report_type, :location_ids, :customer_ids, :recipient_emails, :days_back
|
|
28
|
+
|
|
29
|
+
def initialize(report_type:, location_ids: nil, customer_ids: nil, recipient_emails: [], days_back: 30)
|
|
30
|
+
raise ArgumentError, "Invalid report_type: #{report_type}" unless VALID_TYPES.include?(report_type.to_s)
|
|
31
|
+
@report_type = report_type.to_s
|
|
32
|
+
@location_ids = location_ids
|
|
33
|
+
@customer_ids = customer_ids
|
|
34
|
+
@recipient_emails = Array(recipient_emails).compact.map(&:strip).reject(&:blank?)
|
|
35
|
+
@days_back = days_back.to_i
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Called by Delayed::Job worker
|
|
39
|
+
def perform
|
|
40
|
+
Rails.logger.info("[YourAiInsight::ScheduledReportJob] Starting #{report_type} | days_back=#{days_back} | recipients=#{recipient_emails.count}")
|
|
41
|
+
|
|
42
|
+
date_range = days_back.days.ago.beginning_of_day..Time.current
|
|
43
|
+
|
|
44
|
+
svc = FacilityDataService.new(
|
|
45
|
+
location_ids: location_ids,
|
|
46
|
+
customer_ids: customer_ids,
|
|
47
|
+
date_range: date_range
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
data = case report_type
|
|
51
|
+
when "summary_dashboard" then svc.summary_dashboard_data
|
|
52
|
+
when "compliance_audit" then svc.compliance_audit_data
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
content = ClaudeService.new.generate_report(report_type.to_sym, data)
|
|
56
|
+
|
|
57
|
+
report = AiReport.create!(
|
|
58
|
+
report_type: report_type,
|
|
59
|
+
content: content,
|
|
60
|
+
raw_data: data,
|
|
61
|
+
period_start: date_range.first.to_date,
|
|
62
|
+
period_end: date_range.last.to_date,
|
|
63
|
+
scheduled: true
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if recipient_emails.present?
|
|
67
|
+
AiReportMailer.scheduled_report(
|
|
68
|
+
report: report,
|
|
69
|
+
recipient_emails: recipient_emails,
|
|
70
|
+
days_back: days_back
|
|
71
|
+
).deliver_now
|
|
72
|
+
Rails.logger.info("[YourAiInsight::ScheduledReportJob] Report ##{report.id} emailed to #{recipient_emails.join(', ')}")
|
|
73
|
+
else
|
|
74
|
+
Rails.logger.info("[YourAiInsight::ScheduledReportJob] Report ##{report.id} saved (no email recipients)")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
report
|
|
78
|
+
|
|
79
|
+
rescue YourAiInsight::ApiError => e
|
|
80
|
+
Rails.logger.error("[YourAiInsight::ScheduledReportJob] Claude API error: #{e.message}")
|
|
81
|
+
raise # Let Delayed::Job retry
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
Rails.logger.error("[YourAiInsight::ScheduledReportJob] #{e.class}: #{e.message}")
|
|
84
|
+
e.backtrace&.first(5)&.each { |l| Rails.logger.error(" #{l}") }
|
|
85
|
+
raise
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Enqueue with Delayed::Job (pass options like run_at:, cron:, priority:)
|
|
89
|
+
def enqueue(options = {})
|
|
90
|
+
Delayed::Job.enqueue(self, { queue: "your_ai_insight_reports", priority: 5 }.merge(options))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Shown in Delayed::Job admin UI
|
|
94
|
+
def display_name
|
|
95
|
+
"YourAiInsight::#{report_type.camelize}ReportJob"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
module YourAiInsight
|
|
2
|
+
class AiReportMailer < ActionMailer::Base
|
|
3
|
+
default from: -> { "#{YourAiInsight.config.company_name} <noreply@allproifm.com>" }
|
|
4
|
+
|
|
5
|
+
def scheduled_report(report:, recipient_emails:, days_back:)
|
|
6
|
+
@report = report
|
|
7
|
+
@days_back = days_back
|
|
8
|
+
@company = YourAiInsight.config.company_name
|
|
9
|
+
@logo_url = YourAiInsight.config.logo_url
|
|
10
|
+
@content = report.content
|
|
11
|
+
|
|
12
|
+
mail(
|
|
13
|
+
to: recipient_emails,
|
|
14
|
+
subject: "[#{@company}] #{report.report_type_label} — #{report.period_label}"
|
|
15
|
+
) do |format|
|
|
16
|
+
format.html { render inline: email_html_template }
|
|
17
|
+
format.text { render plain: @content }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def email_html_template
|
|
24
|
+
<<~HTML
|
|
25
|
+
<!DOCTYPE html>
|
|
26
|
+
<html>
|
|
27
|
+
<head>
|
|
28
|
+
<meta charset="utf-8">
|
|
29
|
+
<style>
|
|
30
|
+
body { font-family: -apple-system, Arial, sans-serif; color: #1a1a1a; margin: 0; padding: 0; background: #f5f5f5; }
|
|
31
|
+
.wrapper { max-width: 700px; margin: 32px auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
|
|
32
|
+
.header { background: #1a56db; color: #fff; padding: 24px 32px; }
|
|
33
|
+
.header h1 { margin: 0; font-size: 20px; font-weight: 600; }
|
|
34
|
+
.header p { margin: 4px 0 0; opacity: .8; font-size: 13px; }
|
|
35
|
+
.body { padding: 32px; }
|
|
36
|
+
h2 { color: #1a56db; font-size: 16px; margin: 24px 0 8px; border-bottom: 1px solid #e8e8e8; padding-bottom: 6px; }
|
|
37
|
+
h3 { color: #374151; font-size: 14px; margin: 16px 0 6px; }
|
|
38
|
+
p, li { font-size: 14px; line-height: 1.65; color: #374151; }
|
|
39
|
+
ul { padding-left: 20px; }
|
|
40
|
+
strong { color: #111; }
|
|
41
|
+
.footer { background: #f9f9f9; padding: 16px 32px; font-size: 12px; color: #9ca3af; border-top: 1px solid #e5e7eb; }
|
|
42
|
+
.badge-critical { background: #fef2f2; color: #b91c1c; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
|
|
43
|
+
.badge-warning { background: #fffbeb; color: #92400e; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
|
|
44
|
+
</style>
|
|
45
|
+
</head>
|
|
46
|
+
<body>
|
|
47
|
+
<div class="wrapper">
|
|
48
|
+
<div class="header">
|
|
49
|
+
#{@logo_url ? "<img src='#{@logo_url}' height='32' style='margin-bottom:12px;'><br>" : ''}
|
|
50
|
+
<h1>#{@report.report_type_label}</h1>
|
|
51
|
+
<p>#{@report.period_label} · Generated #{Time.current.strftime('%b %d, %Y at %I:%M %p')}</p>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="body">
|
|
54
|
+
#{markdown_to_html(@content)}
|
|
55
|
+
</div>
|
|
56
|
+
<div class="footer">
|
|
57
|
+
This report was automatically generated by YourAiInsight for #{@company}.<br>
|
|
58
|
+
Covering the past #{@days_back} days of facility data.
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</body>
|
|
62
|
+
</html>
|
|
63
|
+
HTML
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def markdown_to_html(text)
|
|
67
|
+
return "<p>#{text}</p>" if text.blank?
|
|
68
|
+
text
|
|
69
|
+
.gsub(/^## (.+)$/) { "<h2>#{$1.gsub(/⚠️|⚡/, '')}</h2>" }
|
|
70
|
+
.gsub(/^### (.+)$/) { "<h3>#{$1}</h3>" }
|
|
71
|
+
.gsub(/⚠️ CRITICAL/) { '<span class="badge-critical">⚠️ CRITICAL</span>' }
|
|
72
|
+
.gsub(/⚡ WARNING/) { '<span class="badge-warning">⚡ WARNING</span>' }
|
|
73
|
+
.gsub(/\*\*(.+?)\*\*/) { "<strong>#{$1}</strong>" }
|
|
74
|
+
.gsub(/^- (.+)$/) { "<li>#{$1}</li>" }
|
|
75
|
+
.gsub(%r{((?:<li>.*</li>\n?)+)}) { "<ul>#{$1}</ul>" }
|
|
76
|
+
.gsub(/\n\n+/, "</p><p>")
|
|
77
|
+
.then { |t| "<p>#{t}</p>" }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module YourAiInsight
|
|
2
|
+
class AiReport < ActiveRecord::Base
|
|
3
|
+
self.table_name = "your_ai_insight_reports"
|
|
4
|
+
|
|
5
|
+
validates :report_type, presence: true,
|
|
6
|
+
inclusion: { in: %w[summary_dashboard compliance_audit] }
|
|
7
|
+
validates :content, presence: true
|
|
8
|
+
|
|
9
|
+
scope :dashboards, -> { where(report_type: "summary_dashboard") }
|
|
10
|
+
scope :compliance, -> { where(report_type: "compliance_audit") }
|
|
11
|
+
scope :scheduled, -> { where(scheduled: true) }
|
|
12
|
+
scope :on_demand, -> { where(scheduled: false) }
|
|
13
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
14
|
+
|
|
15
|
+
def report_type_label
|
|
16
|
+
report_type == "summary_dashboard" ? "Summary Dashboard" : "Compliance & Audit Report"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def period_label
|
|
20
|
+
return "" unless period_start && period_end
|
|
21
|
+
"#{period_start.strftime('%b %d')} – #{period_end.strftime('%b %d, %Y')}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|