sage-rails 0.0.6 → 0.0.8

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: 8d2b97b78686051a12d330c24039849f41cbd889c2039365e87774576d2a1ac9
4
- data.tar.gz: c5562cfa797cc19749359f4ca889653cbe33b9159ddfac70221df43a7e928c8c
3
+ metadata.gz: 0d4771d99542d22d3e3665bd1d633decc10a4b9246fc867d48bb9b6c63598060
4
+ data.tar.gz: bf330ca38aff34abb0a3df19d09d937e2235d659a98f30143082569c1aea9aa3
5
5
  SHA512:
6
- metadata.gz: c50a7df5ab0b1786ee56b4a2042761b5da2c6d80639f0231e05c909301993f7d0569559446a7cdee8bb2b135c6de2cabfdba0611c601f77ed859b1ac5b0e9624
7
- data.tar.gz: 6e7c54a17bf338115923d833800486558c5a3d6be0515e77f7c29a44c07aae1dae0690a361419d8cf5cf0cb8c2a5f60dad34378b59696090cd6619f8d05d3be9
6
+ metadata.gz: db485b33ee21c595f40eb29e9d0e9aa62514ddd67db154ee0022757934ceedd7382183bef532f3566a4c13f6ec5d0a3bf9a2f44d6fd47835c665888752fd9d80
7
+ data.tar.gz: 51bcfe5e82dc8350c5c6294b75b1859e9e0bc6f24ed175d06c950a849c57b416fd86ad7956940c2a498c7cc10d1883d758382345e64e2161762cee62a7cc2d01
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,42 @@
1
+ # Contributing
2
+
3
+ First, thanks for wanting to contribute. You’re awesome! :heart:
4
+
5
+ ## Help
6
+
7
+ We’re not able to provide support through GitHub Issues. If you’re looking for help with your code, try posting on [Stack Overflow](https://stackoverflow.com/).
8
+
9
+ All features should be documented. If you don’t see a feature in the docs, assume it doesn’t exist.
10
+
11
+ ## Bugs
12
+
13
+ Think you’ve discovered a bug?
14
+
15
+ 1. Search existing issues to see if it’s been reported.
16
+ 2. Try the `master` branch to make sure it hasn’t been fixed.
17
+
18
+ ```rb
19
+ gem "sage-rails", github: "mrjonesbot/sage"
20
+ ```
21
+
22
+ If the above steps don’t help, create an issue. Include:
23
+
24
+ - Detailed steps to reproduce
25
+ - Complete backtraces for exceptions
26
+
27
+ ## New Features
28
+
29
+ If you’d like to discuss a new feature, create an issue and start the title with `[Idea]`.
30
+
31
+ ## Pull Requests
32
+
33
+ Fork the project and create a pull request. A few tips:
34
+
35
+ - Keep changes to a minimum. If you have multiple features or fixes, submit multiple pull requests.
36
+ - Follow the existing style. The code should read like it’s written by a single person.
37
+
38
+ Feel free to open an issue to get feedback on your idea before spending too much time on it.
39
+
40
+ ---
41
+
42
+ This contributing guide is released under [CCO](https://creativecommons.org/publicdomain/zero/1.0/) (public domain). Use it for your own project without attribution.
data/README.md CHANGED
@@ -2,10 +2,25 @@
2
2
 
3
3
  ![Sage Query Interface](screenshots/sage-query-interface.png)
4
4
 
5
+ > **Note:** This project is pre-v1 and is subject to frequent changes.
6
+
5
7
  **Natural language reporting to help your team build accurate reports, faster.**
6
8
 
7
9
  Sage is a Rails engine built on top of the excellent [Blazer](https://github.com/ankane/blazer) gem, adding an LLM interface to make data exploration accessible via natural language.
8
10
 
11
+ ## Requirements
12
+
13
+ Rails 7.1+ is recommended, compatability with older versions not guaranteed.
14
+
15
+ Sage relies on `turbo-rails` and `stimulus-rails` to render the dashboard. These gems should already be included in most modern Rails applications. If not, add them to your Gemfile:
16
+
17
+ ```ruby
18
+ gem "turbo-rails"
19
+ gem "stimulus-rails"
20
+ ```
21
+
22
+ Refer to the [stimulus-rails installation page](https://github.com/hotwired/stimulus-rails) to get started.
23
+
9
24
  ## Installation
10
25
 
11
26
  Add Sage to your application's Gemfile:
@@ -32,7 +47,6 @@ This generator will:
32
47
  - Mount Sage at `/sage` in your routes
33
48
  - Create an initializer at `config/initializers/sage.rb`
34
49
  - Set up database migrations for message storage
35
- - Configure JavaScript and CSS dependencies
36
50
 
37
51
  After installation, run the migrations:
38
52
  ```bash
@@ -88,26 +102,17 @@ For detailed information on Blazer-specific features, refer to the [Blazer docum
88
102
 
89
103
  ## Database Context
90
104
 
91
- Sage introspects your database schema to provide context for more accurate SQL generation. This feature works out of the box with Blazer's data sources.
105
+ Sage introspects your database schema to provide context for more accurate SQL generation.
92
106
 
93
107
  ### Multiple Data Sources
94
108
 
95
- If you have multiple Blazer data sources configured, Sage will use the appropriate schema for each:
96
-
97
- ```ruby
98
- # config/blazer.yml
99
- data_sources:
100
- main:
101
- url: <%= ENV["DATABASE_URL"] %>
102
- analytics:
103
- url: <%= ENV["ANALYTICS_DATABASE_URL"] %>
104
- ```
109
+ If you have multiple databases, Sage will use the appropriate schema for each:
105
110
 
106
- When querying from different data sources in Blazer, Sage automatically switches schema context.
111
+ When querying different data sources in Blazer, Sage will switch schema context.
107
112
 
108
113
  ## Model Scope Context
109
114
 
110
- Sage leverages your Rails model scopes as documentation for query patterns, dramatically improving the accuracy of generated SQL queries, especially for complex multi-table reports.
115
+ Sage introspects your Rails model scopes to understand common query patterns, dramatically improving the accuracy of generated SQL queries.
111
116
 
112
117
  ### Example
113
118
 
@@ -144,9 +149,8 @@ Sage understands:
144
149
  ### Benefits
145
150
 
146
151
  1. **Business Logic Awareness**: Scopes encode your business rules (what makes a customer "active" or "high-value")
147
- 2. **Correct JOIN Patterns**: Scopes show the proper way to join tables in your application
148
- 3. **Aggregation Patterns**: Complex scopes with GROUP BY and HAVING clauses guide report generation
149
- 4. **Consistency**: Generated queries follow the same patterns as your application code
152
+ 2. **Aggregation Patterns**: Complex scopes with GROUP BY and HAVING clauses guide report generation
153
+ 3. **Consistency**: Generated queries follow the same patterns as your application code
150
154
 
151
155
  Scopes now serve dual purposes:
152
156
  1. Reusable query logic in your Rails application
@@ -186,11 +190,6 @@ $ rails test
186
190
  - Ensure model files are in standard Rails locations (`app/models/`)
187
191
  - Check that Blazer is properly configured and can execute queries
188
192
 
189
- ### Performance
190
- - Use lighter models (Claude Haiku, GPT-3.5) for faster response times
191
- - Consider caching frequently used queries
192
- - Scope context is cached per request to minimize processing
193
-
194
193
  ## Contributing
195
194
 
196
195
  Bug reports and pull requests are welcome on GitHub. This project is intended to be a safe, welcoming space for collaboration.
@@ -4,7 +4,7 @@ import "@hotwired/turbo-rails"
4
4
  import { Application } from "@hotwired/stimulus"
5
5
 
6
6
  // Import and register Sage controllers
7
- import { SearchController, ClipboardController, SelectController, DashboardController, ReverseInfiniteScrollController } from "sage"
7
+ import { SearchController, ClipboardController, SelectController, DashboardController, ReverseInfiniteScrollController, VariablesController } from "sage"
8
8
 
9
9
  const application = Application.start()
10
10
  application.debug = true
@@ -16,3 +16,29 @@ application.register("sage--clipboard", ClipboardController)
16
16
  application.register("sage--select", SelectController)
17
17
  application.register("sage--dashboard", DashboardController)
18
18
  application.register("sage--reverse-infinite-scroll", ReverseInfiniteScrollController)
19
+ application.register("sage--variables", VariablesController)
20
+
21
+ // Override Blazer's submitIfCompleted function globally
22
+ window.submitIfCompleted = function($form) {
23
+ // Try to find our Sage variables controller first
24
+ const controller = document.querySelector('[data-controller*="sage--variables"]')
25
+ if (controller && window.Stimulus) {
26
+ const controllerInstance = window.Stimulus.getControllerForElementAndIdentifier(controller, 'sage--variables')
27
+ if (controllerInstance) {
28
+ controllerInstance.submitIfCompleted($form[0] || $form)
29
+ return
30
+ }
31
+ }
32
+
33
+ // Fallback to improved version of original logic
34
+ var completed = true
35
+ $form.find("input[name], select").each(function () {
36
+ const value = $(this).val()
37
+ if (!value || value.toString().trim() === "") {
38
+ completed = false
39
+ }
40
+ })
41
+ if (completed) {
42
+ $form.submit()
43
+ }
44
+ }
@@ -0,0 +1,26 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = { text: String }
5
+
6
+ connect() {
7
+ console.log("Clipboard controller connected");
8
+ }
9
+
10
+ copy(event) {
11
+ event.preventDefault()
12
+
13
+ navigator.clipboard.writeText(this.textValue).then(() => {
14
+ const originalText = event.target.textContent
15
+ event.target.textContent = "Copied!"
16
+ event.target.classList.add("btn-success")
17
+
18
+ setTimeout(() => {
19
+ event.target.textContent = originalText
20
+ event.target.classList.remove("btn-success")
21
+ }, 2000)
22
+ }).catch(err => {
23
+ console.error('Failed to copy text: ', err)
24
+ })
25
+ }
26
+ }
@@ -0,0 +1,132 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Connects to data-controller="dashboard"
4
+ export default class extends Controller {
5
+ static targets = ["queryList", "queryTemplate"];
6
+ static values = { queries: Array };
7
+
8
+ connect() {
9
+ console.log("Dashboard controller connected");
10
+ console.log("Queries value:", this.queriesValue);
11
+
12
+ // Parse the queries value if it's a string
13
+ const queriesData = typeof this.queriesValue === 'string' ?
14
+ JSON.parse(this.queriesValue) : this.queriesValue;
15
+ this.selectedQueries = Array.isArray(queriesData) ? [...queriesData] : [];
16
+
17
+ console.log("Selected queries:", this.selectedQueries);
18
+
19
+ this.render();
20
+ this.setupSortable();
21
+ }
22
+
23
+ addQuery(event) {
24
+ const { value, text } = event.detail;
25
+
26
+ // Check for duplicates and remove if found
27
+ const existingIndex = this.selectedQueries.findIndex(q => q.id == value);
28
+ if (existingIndex !== -1) {
29
+ this.selectedQueries.splice(existingIndex, 1);
30
+ }
31
+
32
+ // Add the new query
33
+ this.selectedQueries.push({ id: value, name: text });
34
+ this.render();
35
+ }
36
+
37
+ removeQuery(event) {
38
+ const index = parseInt(event.params.index);
39
+ this.selectedQueries.splice(index, 1);
40
+ this.render();
41
+ }
42
+
43
+ render() {
44
+ // Check if the target exists
45
+ if (!this.hasQueryListTarget) {
46
+ console.error("queryList target not found");
47
+ return;
48
+ }
49
+
50
+ if (this.selectedQueries.length === 0) {
51
+ this.queryListTarget.style.display = 'none';
52
+ return;
53
+ }
54
+
55
+ this.queryListTarget.style.display = 'block';
56
+ let queriesContainer = this.queryListTarget.querySelector('#queries');
57
+
58
+ // If the container doesn't exist, create it
59
+ if (!queriesContainer) {
60
+ queriesContainer = document.createElement('div');
61
+ queriesContainer.id = 'queries';
62
+ queriesContainer.style.display = 'flex';
63
+ queriesContainer.style.flexDirection = 'column';
64
+ queriesContainer.style.gap = '8px';
65
+ this.queryListTarget.appendChild(queriesContainer);
66
+ }
67
+
68
+ queriesContainer.innerHTML = '';
69
+
70
+ this.selectedQueries.forEach((query, index) => {
71
+ // Create a plain div without any Beer CSS classes
72
+ const item = document.createElement('div');
73
+ // Remove ALL classes and use inline styles only
74
+ item.style.cssText = `
75
+ display: flex !important;
76
+ align-items: center !important;
77
+ width: 100% !important;
78
+ padding: 12px !important;
79
+ margin-bottom: 8px !important;
80
+ background-color: #f5f5f5 !important;
81
+ border-radius: 8px !important;
82
+ opacity: 1 !important;
83
+ filter: none !important;
84
+ `;
85
+
86
+ // Use a Material Icons checkbox instead of HTML input
87
+ const checkIcon = document.createElement('i');
88
+ checkIcon.className = 'material-icons';
89
+ checkIcon.textContent = 'check_box';
90
+ checkIcon.style.cssText = 'color: #6750A4 !important; margin-right: 12px !important; font-size: 24px !important;';
91
+
92
+ const text = document.createElement('strong'); // Use strong tag
93
+ text.textContent = query.name;
94
+ text.style.cssText = 'flex: 1 !important; color: #000000 !important; opacity: 1 !important; filter: none !important;';
95
+
96
+ const closeBtn = document.createElement('i');
97
+ closeBtn.className = 'material-icons';
98
+ closeBtn.textContent = 'close';
99
+ closeBtn.style.cssText = 'cursor: pointer !important; color: #000000 !important; opacity: 1 !important; font-size: 20px !important;';
100
+ closeBtn.setAttribute('data-action', 'click->sage--dashboard#removeQuery');
101
+ closeBtn.setAttribute('data-sage--dashboard-index-param', index);
102
+
103
+ const hiddenInput = document.createElement('input');
104
+ hiddenInput.type = 'hidden';
105
+ hiddenInput.name = 'query_ids[]';
106
+ hiddenInput.value = query.id;
107
+
108
+ item.appendChild(checkIcon);
109
+ item.appendChild(text);
110
+ item.appendChild(closeBtn);
111
+ item.appendChild(hiddenInput);
112
+
113
+ queriesContainer.appendChild(item);
114
+ });
115
+ }
116
+
117
+ setupSortable() {
118
+ if (typeof Sortable !== 'undefined') {
119
+ const queriesContainer = this.queryListTarget.querySelector('#queries');
120
+ if (queriesContainer) {
121
+ Sortable.create(queriesContainer, {
122
+ onEnd: (e) => {
123
+ // Move the item in our array to match the new position
124
+ const movedItem = this.selectedQueries.splice(e.oldIndex, 1)[0];
125
+ this.selectedQueries.splice(e.newIndex, 0, movedItem);
126
+ this.render();
127
+ }
128
+ });
129
+ }
130
+ }
131
+ }
132
+ }
@@ -0,0 +1,146 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ console.log("Reverse infinite scroll controller file loaded!");
4
+
5
+ export default class extends Controller {
6
+ static targets = ["entries", "pagination"];
7
+ static values = {
8
+ url: String,
9
+ page: Number,
10
+ loading: Boolean,
11
+ };
12
+
13
+ initialize() {
14
+ console.log("Reverse infinite scroll controller initialized");
15
+ this.intersectionObserver = new IntersectionObserver(
16
+ (entries) => {
17
+ entries.forEach((entry) => {
18
+ if (entry.isIntersecting) {
19
+ console.log("Pagination target is visible - loading older messages");
20
+ this.loadOlder();
21
+ }
22
+ });
23
+ },
24
+ {
25
+ rootMargin: "50px 0px 50px 0px",
26
+ threshold: 0.1,
27
+ }
28
+ );
29
+ }
30
+
31
+ connect() {
32
+ console.log("Reverse infinite scroll controller connected");
33
+ console.log("Initial page value:", this.pageValue);
34
+
35
+ if (this.hasPaginationTarget) {
36
+ console.log("Found pagination target, observing...");
37
+ this.intersectionObserver.observe(this.paginationTarget);
38
+ } else {
39
+ console.log("No pagination target found");
40
+ }
41
+ }
42
+
43
+ paginationTargetConnected(target) {
44
+ console.log("Pagination target connected dynamically, observing...");
45
+ // Add a delay to allow scroll-to-bottom to happen first
46
+ setTimeout(() => {
47
+ console.log("Starting to observe pagination target after delay");
48
+ this.intersectionObserver.observe(target);
49
+ }, 1000);
50
+ }
51
+
52
+ paginationTargetDisconnected(target) {
53
+ console.log("Pagination target disconnected, stop observing...");
54
+ this.intersectionObserver.unobserve(target);
55
+ }
56
+
57
+ disconnect() {
58
+ console.log("Reverse infinite scroll controller disconnected");
59
+ this.intersectionObserver.disconnect();
60
+ }
61
+
62
+ buildUrl(nextPage) {
63
+ const url = new URL(this.urlValue, window.location.origin);
64
+ url.searchParams.set("page", nextPage);
65
+ return url.toString();
66
+ }
67
+
68
+ async loadOlder() {
69
+ if (this.loadingValue) {
70
+ console.log("Already loading, skipping...");
71
+ return;
72
+ }
73
+
74
+ console.log("Loading older messages...");
75
+ console.log("Current pageValue:", this.pageValue);
76
+ this.loadingValue = true;
77
+ const nextPage = this.pageValue + 1;
78
+ console.log("Requesting page:", nextPage);
79
+
80
+ // Store current scroll position
81
+ const oldScrollHeight = this.element.scrollHeight;
82
+ const oldScrollTop = this.element.scrollTop;
83
+
84
+ try {
85
+ const url = this.buildUrl(nextPage);
86
+ console.log("Fetching URL:", url);
87
+
88
+ const response = await fetch(url, {
89
+ headers: {
90
+ Accept: "text/vnd.turbo-stream.html",
91
+ },
92
+ });
93
+
94
+ if (!response.ok) {
95
+ throw new Error(`HTTP error! status: ${response.status}`);
96
+ }
97
+
98
+ const html = await response.text();
99
+ console.log("Received response for page", nextPage);
100
+
101
+ // Parse the turbo-stream content
102
+ const parser = new DOMParser();
103
+ const doc = parser.parseFromString(html, "text/html");
104
+ const template = doc.querySelector("template");
105
+
106
+ if (template) {
107
+ // Remove the current pagination target before appending new content
108
+ if (this.hasPaginationTarget) {
109
+ this.paginationTarget.remove();
110
+ }
111
+
112
+ // Manually prepend the template content (for reverse infinite scroll)
113
+ this.entriesTarget.insertAdjacentHTML("afterbegin", template.innerHTML);
114
+ this.pageValue = nextPage;
115
+ console.log(`Updated to page ${nextPage}`);
116
+
117
+ // Maintain scroll position (prevent jumping to top)
118
+ setTimeout(() => {
119
+ const newScrollHeight = this.element.scrollHeight;
120
+ const heightDifference = newScrollHeight - oldScrollHeight;
121
+ this.element.scrollTop = oldScrollTop + heightDifference;
122
+
123
+ console.log("Scroll position updated:", {
124
+ oldScrollHeight,
125
+ newScrollHeight,
126
+ heightDifference,
127
+ newScrollTop: this.element.scrollTop
128
+ });
129
+
130
+ // Set up observer for new pagination target if it exists
131
+ if (this.hasPaginationTarget) {
132
+ console.log("Found new pagination target, observing...");
133
+ this.intersectionObserver.observe(this.paginationTarget);
134
+ } else {
135
+ console.log("No more pages to load");
136
+ }
137
+ }, 50);
138
+ }
139
+ } catch (error) {
140
+ console.error("Error loading older messages:", error);
141
+ console.log("Staying on page", this.pageValue, "due to error");
142
+ } finally {
143
+ this.loadingValue = false;
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,47 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import debounce from "debounce";
3
+
4
+ // Connects to data-controller="search"
5
+ export default class extends Controller {
6
+ initialize() {
7
+ this.submit = debounce(this.submit.bind(this), 300);
8
+ this.searchInput = null;
9
+ }
10
+
11
+ connect() {
12
+ console.log("Search controller initialized");
13
+ // Find the search input
14
+ this.searchInput = this.element.querySelector('input[type="search"]');
15
+
16
+ if (this.searchInput) {
17
+ // Focus the input
18
+ if (this.searchInput.value.length > 0) {
19
+ this.searchInput.focus();
20
+ }
21
+
22
+ // If we have a stored cursor position, restore it
23
+ if (this.cursorPosition !== undefined) {
24
+ this.searchInput.setSelectionRange(
25
+ this.cursorPosition,
26
+ this.cursorPosition
27
+ );
28
+ // Clear the stored position after using it
29
+ this.cursorPosition = undefined;
30
+ } else {
31
+ // If no stored position, place cursor at the end
32
+ const length = this.searchInput.value.length;
33
+ this.searchInput.setSelectionRange(length, length);
34
+ }
35
+ }
36
+ }
37
+
38
+ submit(event) {
39
+ // Store the cursor position before submitting
40
+ if (this.searchInput) {
41
+ this.cursorPosition = this.searchInput.selectionStart;
42
+ }
43
+
44
+ this.element.requestSubmit();
45
+ }
46
+ }
47
+