Major rework of the command/ai abstractions

- Big simplification of the overall workflow.
- Composite commands store their commands properly, these are undoable now.
- Child commands are excluded from history
This commit is contained in:
Jorge Manrubia
2025-05-15 15:04:43 +02:00
parent cb7404d091
commit 2510c1e0ac
18 changed files with 386 additions and 266 deletions
+11 -33
View File
@@ -1,6 +1,6 @@
class CommandsController < ApplicationController
def index
@commands = Current.user.commands.order(created_at: :desc).limit(20).reverse
@commands = Current.user.commands.root.order(created_at: :desc).limit(20).reverse
end
def create
@@ -15,7 +15,7 @@ class CommandsController < ApplicationController
respond_with_needs_confirmation(command)
end
else
respond_with_error
respond_with_error(command)
end
end
@@ -38,10 +38,10 @@ class CommandsController < ApplicationController
def respond_with_execution_result(result)
case result
when Array
respond_with_composite_response(result)
when Command::Result::Redirection
redirect_to result.url
when Command::Result::ChatResponse
respond_with_chat_response(result)
when Command::Result::InsightResponse
respond_with_insight_response(result)
else
@@ -49,42 +49,20 @@ class CommandsController < ApplicationController
end
end
def respond_with_chat_response(result)
command = command_from_chat_response(result)
if confirmed?(command)
if result.has_context_url? && params["redirected"].blank?
respond_with_needs_redirection redirect_to: result.context_url
elsif command.valid?
chat_response_result = command.execute
respond_with_execution_result chat_response_result
else
respond_with_error
end
else
respond_with_needs_confirmation(command.commands, redirect_to: result.context_url)
end
def respond_with_needs_confirmation(command)
render json: { confirmation: command.confirmation_prompt, redirect_to: command.context.url }, status: :conflict
end
def respond_with_needs_confirmation(commands, redirect_to: nil)
render json: { commands: Array(commands).collect(&:title), redirect_to: redirect_to }, status: :conflict
end
def respond_with_needs_redirection(redirect_to:)
render json: { redirect_to: redirect_to }, status: :conflict
end
def command_from_chat_response(chat_response)
context = Command::Parser::Context.new(Current.user, url: chat_response.context_url || request.referrer)
parser = Command::Parser.new(context)
Command::Composite.new(chat_response.command_lines.collect { parser.parse it })
def respond_with_composite_response(results)
json = results.map(&:as_json).grep(Hash).reduce({}, :merge)
render json: json, status: :accepted
end
def respond_with_insight_response(chat_response)
render json: { message: chat_response.content }, status: :accepted
end
def respond_with_error
head :unprocessable_entity
def respond_with_error(command)
render json: { error: command.errors.full_messages.join(", "), context_url: command.context.url }, status: :unprocessable_entity
end
end
@@ -3,13 +3,12 @@ import { HttpStatus } from "helpers/http_helpers"
import { marked } from "marked"
export default class extends Controller {
static targets = [ "input", "form", "output", "confirmation", "redirected" ]
static targets = [ "input", "form", "output", "confirmation" ]
static classes = [ "error", "confirmation", "help", "output", "busy" ]
static values = { originalInput: String, waitingForConfirmation: Boolean, autoSubmitAfterRedirection: Boolean }
static values = { originalInput: String, waitingForConfirmation: Boolean }
connect() {
if (this.waitingForConfirmationValue) { this.focus() }
if (this.autoSubmitAfterRedirectionValue) { this.#submitAfterRedirection() }
}
// Actions
@@ -87,7 +86,7 @@ export default class extends Controller {
#handleSuccessResponse(response) {
if (response.headers.get("Content-Type")?.includes("application/json")) {
response.json().then((responseJson) => {
this.#showOutput(marked.parse(responseJson.message))
this.#handleJsonResponse(responseJson)
})
}
this.#reset()
@@ -106,10 +105,8 @@ export default class extends Controller {
#reset(inputValue = "") {
this.inputTarget.value = inputValue
this.confirmationTarget.value = ""
this.redirectedTarget.value = ""
this.waitingForConfirmationValue = false
this.originalInputValue = null
this.autoSubmitAfterRedirectionValue = false
this.element.classList.remove(this.errorClass)
this.element.classList.remove(this.confirmationClass)
@@ -121,44 +118,32 @@ export default class extends Controller {
}
async #handleConflictResponse(response) {
const { commands, redirect_to } = await response.json()
this.originalInputValue = this.inputTarget.value
this.#handleJsonResponse(await response.json())
}
if (commands && commands.length) {
this.#requestConfirmation(commands)
} else {
this.autoSubmitAfterRedirectionValue = true
#handleJsonResponse(responseJson) {
const { confirmation, message, redirect_to } = responseJson
if (message) {
this.#showOutput(marked.parse(message))
}
if (confirmation) {
this.#requestConfirmation(confirmation)
}
if (redirect_to) {
Turbo.visit(redirect_to, { frame: "cards" })
Turbo.visit(redirect_to)
}
}
async #requestConfirmation(commands) {
async #requestConfirmation(confirmationPrompt) {
this.element.classList.add(this.confirmationClass)
if (commands.length > 1) {
this.#requestMultiLineConfirmation(commands)
} else {
this.#requestSingleLineConfirmation(commands)
}
this.inputTarget.value = `${confirmationPrompt}? [Y/n] `
this.waitingForConfirmationValue = true
}
#requestMultiLineConfirmation(commands) {
const commandsDescription = commands.map(command => `- ${command}`).join(`<br>`)
const message = `This will:<br>${commandsDescription}`
this.#showOutput(message)
this.inputTarget.value = `Do you confirm? [Y/n] `
}
#requestSingleLineConfirmation(commands) {
const message = commands[0]
this.inputTarget.value = `${message}? [Y/n] `
}
#handleConfirmationKey(key) {
if (key === "enter" || key === "y") {
this.#submitWithConfirmation()
@@ -176,12 +161,6 @@ export default class extends Controller {
this.#reset()
}
#submitAfterRedirection() {
this.inputTarget.value = this.originalInputValue
this.redirectedTarget.value = "redirected"
this.formTarget.requestSubmit()
}
#showOutput(html) {
this.element.classList.add(this.outputClass)
this.outputTarget.innerHTML = html
+7
View File
@@ -2,6 +2,9 @@ class Command < ApplicationRecord
include Rails.application.routes.url_helpers
belongs_to :user
belongs_to :parent, class_name: "Command", optional: true
scope :root, ->{ where(parent_id: nil) }
attribute :context
@@ -9,6 +12,10 @@ class Command < ApplicationRecord
model_name.human
end
def confirmation_prompt
title
end
def execute
end
+65
View File
@@ -0,0 +1,65 @@
class Command::Ai::Parser
include Rails.application.routes.url_helpers
attr_reader :context
delegate :user, to: :context
def initialize(context)
@context = context
end
def parse(query)
query_json = resolve_named_params_to_ids command_translator.as_normalized_json(query)
build_chat_response_with query, query_json
end
private
def command_translator
Command::Ai::Translator.new(context)
end
def resolve_named_params_to_ids(query_json)
query_json.tap do |query_json|
if query_context = query_json["context"].presence
query_context["assignee_ids"] = query_context["assignee_ids"]&.filter_map { |name| assignee_from(name)&.id }
query_context["creator_id"] = assignee_from(query_context["creator_id"])&.id if query_context["creator_id"]
query_context["collection_ids"] = query_context["collection_ids"]&.filter_map { |name| Collection.where("lower(name) = ?", name.downcase).first&.id }
query_context["tag_ids"] = query_context["tag_ids"]&.filter_map { |name| ::Tag.find_by_title(name)&.id }
query_context.compact!
end
end
end
def assignee_from(string)
string_without_at = string.delete_prefix("@")
User.all.find { |user| user.mentionable_handles.include?(string_without_at) }
end
def build_chat_response_with(query, query_json)
query_context = context_from_query(query_json)
resolved_context = query_context || context
commands = Array.wrap(commands_from_query(query_json, resolved_context))
if query_context
commands.unshift Command::VisitUrl.new(user: user, url: query_context.url, context: resolved_context)
end
Command::Composite.new(title: query, commands: commands, user: user, line: query, context: resolved_context)
end
def context_from_query(query_json)
if context_properties = query_json["context"].presence
url = cards_path(**context_properties)
Command::Parser::Context.new(user, url: url)
end
end
def commands_from_query(query_json, context)
parser = Command::Parser.new(context)
if command_lines = query_json["commands"].presence
command_lines.collect { parser.parse(it) }
end
end
end
+148
View File
@@ -0,0 +1,148 @@
class Command::Ai::Translator
attr_reader :context
def initialize(context)
@context = context
end
def as_normalized_json(query)
response = chat.ask query
Rails.logger.info "*** Commands: #{response.content}"
compact JSON.parse(response.content)
end
private
def chat
chat = ::RubyLLM.chat
chat.with_instructions(prompt)
end
def prompt
<<~PROMPT
You are Fizzys command translator. Given a request by the user you should:
1. Read the users request
2. Consult the current view
3. Determine if you need a new context to resolve the query or if the current view is enough.
4. Create as many commands as you need to satisfy the request.
5. Output a JSON object that contains:
* The new context properties, when a new context is needed.
* A **single JSON array** of command objects to execute
Fizzy data includes cards and comments contained in those. A card can represent an issue, a feature,
a bug, a task, etc. Cards are contained in collections.
## Current view:
The user is currently #{context_description} }.
## Determine context
If the query seems to refer to filtering certain cards, try to satisfy the filter with the supported options:
* terms: a list of terms to search for. Use this option to refine searches based on further keyword*based
queries. Pass an array even when it's only one term. Always send individual terms separated by spaces.
E.g: ["some", "term"] instead of ["some term"].
* indexed_by: can be "newest", "oldest", "latest", "stalled", "closed"
* assignee_ids: the name of the person or persons assigned to the card.
* assignment_status: only used to filter unassigned cards with "unassigned".
* engagement_status: can be "considering" or "doing". This refers to whether the team is working on something.
* card_ids: a list of card ids
* creator_id: the name of a person
* collection_ids: a list of collection names. Cards are contained in collections. Don't use unless mentioning
specific collections.
* tag_ids: a list of tag names.
What to use to filter:
- To filter cards assigned to people, use "assignee_ids" and pass the name of the person or persons.
- To filter cards created by someone, use "creator_id".
- To filter cards with certain tags, use "tag_ids" and pass the name of the tag or tags.
- To filter unassigned cards, use "assignment_status" and pass "unassigned".
- To filter cards by certain subject, use "terms" to pass relevant keywords. Avoid generic keywords,
extract the relevant ones.
If a new context is needed, the output json will contain a "context"" property with the required properties:
Example: { context: { terms: ["design", "title"] }, assignee_ids: ["jz"], tag_ids: ["design"], commands: [.....] }
When the user is in a view "not seeing cards", then always create a context to satisfy the request.
If the query does not refer to filtering certain cards, the output won't contain any context key. Notice there is already a
context in the current view, and, unless the request indicate otherwise, that's the context you should assume.
If you can't infer clear commands or filtering conditions, just create a context that searches using "terms" derived from
the query.
## Supported commands:
A command is represented with simple string that contains the command preffixed with / and may contain additional params
separated by spaces. The supported commands are:
- Assign users to cards: Syntax: /assign [user]. The user can be prefixed with @. Example: "/assign kevin" or "/assign @kevin"
- Close cards: Syntax: /close [optional reason]. Example: "/close" or "/close not now"
- Tag cards with certain one or multiple tags: Syntax: /tag [tag-name]. The tag can be prefixed with #. Example: "/tag performance" or "/tag #peformance"
- Clear filters: Syntax: /clear
- Get AI insight about cards: Syntax: /insight [query]. Example: "/insight summarize". Notice that this can be combined with
creating a new context to get insight from.
Notice that commands can be combined with filtering to specify the context. For example: "close cards assigned to jorge" should
generate a context to search cards assigned to jorge and a /close command to act on those.
Some queries simply require creating a new context and not running any command. For example "cards assigned to jorge",
"issues about headlines tagged with #design" are mere filtering requests that are satisfied by creating a new context. Don't
' add a "commands" property with an empty array, just avoid the property in these cases.
When you do need to create new commands, append them to the JSON under the "commands" property:
Example: { commands: [ "/assign jorge", "/insight summarize performance issues" ] }
Notice that commands and context can be combined if needed:
Example: { context: { terms: ["design", "title"] }, commands: [ "/assign jorge" ] }
Make sure you create as many commands as you need to satisfy the request.
## JSON format
Each command will be a JSON object containing two properties: "commands" and "context". Notice that both are optional
but at least ONE must be present. All these examples are valid:
{ context: [ terms: [ "performance" ] ], commands: ["/assign jorge", "/close"] }
{ context: [ terms: [ "performance" ] ] }
{ commands: [ "/assign jorge", "/close"] }
# Other
* Avoid empty preambles like "Based on the provided cards". Be friendly, favor an active voice.
* Be concise and direct.
* When emitting search commands, if searching for terms, remove generic ones.
* Don't use a "terms" filter for expressions added as other context properties or commands. E.g: if filtering cards assigned to
"jorge"", don't filter by terms "jorge" too. If tagging with "design", don't filter by the term "design" too.
for those terms too. If assigning a tag, don't search that tag too.
* An unassigned card is a card without assignees.
* Never create a /search or /insight without additional params.
* An unassigned card can be closed or not. "unassigned" and "closed" are different unrelated concepts.
* An unassigned card can be "considering" or "doing". "unassigned" and "engagement_status" are different unrelated concepts.
* Only use assignment_status asking for unassigned cards. Never use in other circumstances.
PROMPT
end
def context_description
if context.viewing_card_contents?
"inside a card"
elsif context.viewing_list_of_cards?
"viewing a list of cards"
else
"not seeing cards"
end
end
def compact(json)
context = json["context"]
context&.each do |key, value|
context[key] = value.presence
end
context&.compact!
json.compact
end
end
-153
View File
@@ -1,153 +0,0 @@
class Command::ChatQuery < Command
store_accessor :data, :query, :params
def title
"Chat query '#{query}'"
end
def execute
response = chat.ask query
Rails.logger.info "*** Commands: #{response.content}"
generated_commands = replace_names_with_ids(JSON.parse(response.content))
build_chat_response_with generated_commands
end
private
def chat
chat = RubyLLM.chat
chat.with_instructions(prompt)
end
# TODO:
# - Don't generate initial /search if not requested. "Assign to JZ" should
def prompt
<<~PROMPT
You are Fizzys command translator. Read the users request, consult the current view, and output
a **single JSON array** of command objects. Return nothing except that JSON.
Fizzy data includes cards and comments contained in those. A card can represent an issue, a feature,
a bug, a task, etc.
## Current context:
The user is currently #{context.viewing_card_contents? ? 'inside a card' : 'viewing a list of cards' }.
## Supported commands:
- Assign users to cards: Syntax: /assign [user]. Example: "/assign kevin"
- Close cards: Syntax: /close [optional reason]. Example: "/close" or "/close not now"
- Tag cards: Syntax: /tag [tag-name]. Example: "/tag performance"
- Clear filters: Syntax: /clear
- Get insight about cards: Syntax: /insight [query]. Example: "/insight summarize performance issues".
- Search cards based on certain keywords: Syntax: /search. It supports the following parameters:
* assignment_status: only used to filter unassigned cards with "unassigned".
* terms: a list of terms to search for. Use this option to refine searches based on further keyword*based
queries. Use the plural terms even when it's only one term. Always send individual terms separated by spaces.
E.g: ["some", "term"] instead of ["some term"].
* indexed_by: can be "newest", "oldest", "latest", "stalled", "closed"
* engagement_status: can be "considering" or "doing". This refers to whether the team is working on something.
* card_ids: a list of card ids
* assignee_ids: a list of assignee names
* creator_id: the name of a person
* collection_ids: a list of collection names. Cards are contained in collections. Don't use unless mentioning
specific collections.
* tag_ids: a list of tag names.
## How to translate requests into commands
1. Determine if you have the right context based on the "current context":
- If it is is "inside a card", assume you are in the right context.
- If it is "viewing a list of cards":
a) consider emitting a /search command to filter the cards.
b) consider emitting also a /insight command to refine context if needed. Don't do this when just asking for certain
terms, only when the request justifies it. Pass the original query verbatim
to insight as the [query]. If the query is "why is it taking so long?", add "/insight why is it taking so long?".
2. Create the sequence of commands to satisfy the user's request.
- If the request is about answering some question about cards, add an /insight command. You can only
add ONE /insight command in total.
- If it is "viewing a list of cards", before emitting the /insight command, consider emitting a /search command
to create the right context to extract the insight from.
- If the request requires acting on cards, add the sequence of commands that satisfy those. You can combine
all of them except /search and /insight, which have an special consideration.
## How to filter cards
- Find cards closed by someone with: (1) /search with indexed_by=closed and assignee_id=someone".
- When asking for assigned cards, use assignee_ids not assignment_status.
- If it's not clear what the user is asking for, perform a /search passing the original query as terms. E.g: for
"red car" add { command: "/search", terms: ["red", "car"] }.
## JSON format
Each command will be a JSON object like. All the commands JSON objects a "command" key with the command.
{ command: "/close" }
The "/search"" command can contain additional keys for the params in the JSON:
{ command: "/search", indexed_by: "closed", collection_ids: [ "Writebook", "Design" ] }
The rest of commands will only have a "command" key, nothing else.
The output will be a single list of JSON objects. Make sure to place values in double quotes and
that you generate valid JSON. Always respond with a list like [ { }, { }, ...]
# Other
* Avoid empty preambles like "Based on the provided cards". Be friendly, favor an active voice.
* Be concise and direct.
* When emitting search commands, if searching for terms, remove generic ones.
* The response can't contain more than one /search command.
* The response can't contain more than one /insight command.
* An unassigned card is a card without assignees.
* Never create a /search or /insight without additional params.
* An unassigned card can be closed or not. "unassigned" and "closed" are different unrelated concepts.
* An unassigned card can be "considering" or "doing". "unassigned" and "engagement_status" are different unrelated concepts.
* Only use assignment_status asking for unassigned cards. Never use in other circumstances.
* There are similar commands to filter and act on cards (e.g: filter by assignee or assign
cards). Favor filtering/queries for commands like "cards assigned to someone".
PROMPT
end
def replace_names_with_ids(commands)
commands.each do |command|
if command["command"] == "/search"
command["assignee_ids"] = command["assignee_ids"]&.filter_map { |name| assignee_from(name)&.id }
command["creator_id"] = assignee_from(command["creator_id"])&.id if command["creator_id"]
command["collection_ids"] = command["collection_ids"]&.filter_map { |name| Collection.where("lower(name) = ?", name.downcase).first&.id }
command["tag_ids"] = command["tag_ids"]&.filter_map { |name| ::Tag.find_by_title(name)&.id }
command.compact!
end
end
end
def assignee_from(string)
string_without_at = string.delete_prefix("@")
User.all.find { |user| user.mentionable_handles.include?(string_without_at) }
end
def build_chat_response_with(generated_commands)
Command::Result::ChatResponse.new \
command_lines: response_command_lines_from(generated_commands),
context_url: response_context_url_from(generated_commands)
end
def response_command_lines_from(generated_commands)
# We translate standalone /search commands as redirections to execute. Otherwise, they
# will be excluded out from the commands to run, as they represent the context url.
#
# TODO: Tidy up this.
if generated_commands.size == 1 && generated_commands.find { it["command"] == "/search" }
[ "/visit #{cards_path(**generated_commands.first.without("command"))}" ]
else
generated_commands.filter { it["command"] != "/search" }.collect { it["command"] }
end
end
def response_context_url_from(generated_commands)
if generated_commands.size > 1 && search_command = generated_commands.find { it["command"] == "/search" }
cards_path(**search_command.without("command"))
end
end
end
+17 -17
View File
@@ -1,21 +1,13 @@
# A composite of commands
class Command::Composite
attr_reader :commands
class Command::Composite < Command
store_accessor :data, :title
def initialize(commands)
@commands = commands
end
def title
@commands.collect(&:title).join(", ")
end
has_many :commands, inverse_of: :parent, dependent: :destroy
def execute
result = nil
ApplicationRecord.transaction do
commands.each { result = it.execute }
commands.collect { it.execute }
end
result
end
def undo
@@ -24,16 +16,24 @@ class Command::Composite
end
end
def undoable?
undoable_commands.any?
end
def confirmation_prompt
commands_excluding_redirections.collect(&:confirmation_prompt).to_sentence
end
def needs_confirmation?
commands.any?(&:needs_confirmation?)
end
def valid?
commands.all?(&:valid?)
end
private
def commands_excluding_redirections
commands.reject { it.is_a?(Command::VisitUrl) }
end
def undoable_commands
commands.filter(&:undoable?)
@undoable_commands ||= commands.filter(&:undoable?)
end
end
+3 -3
View File
@@ -10,8 +10,8 @@ class Command::Parser
def parse(string)
parse_command(string).tap do |command|
command.user = user
command.line = string
command.context = context
command.line ||= string
command.context ||= context
end
end
@@ -65,7 +65,7 @@ class Command::Parser
elsif card = single_card_from(string)
Command::GoToCard.new(card_id: card.id)
else
Command::ChatQuery.new(query: string, params: filter.as_params)
Command::Ai::Parser.new(context).parse(string)
end
end
+5 -3
View File
@@ -1,9 +1,11 @@
class Command::Parser::Context
attr_reader :user
attr_reader :user, :url
def initialize(user, url:)
@user = user
extract_url_components(url)
@url = url
extract_url_components
end
def cards
@@ -31,7 +33,7 @@ class Command::Parser::Context
private
attr_reader :controller, :action, :params
def extract_url_components(url)
def extract_url_components
uri = URI.parse(url || "")
route = Rails.application.routes.recognize_path(uri.path)
@controller = route[:controller]
@@ -1,12 +0,0 @@
class Command::Result::ChatResponse
attr_reader :command_lines, :context_url
def initialize(context_url: nil, command_lines:)
@context_url = context_url
@command_lines = command_lines
end
def has_context_url?
context_url.present?
end
end
+11 -1
View File
@@ -1 +1,11 @@
Command::Result::InsightResponse = Struct.new(:content)
class Command::Result::InsightResponse
attr_reader :content
def initialize(content)
@content = content
end
def as_json
{ message: content }
end
end
+11 -1
View File
@@ -1 +1,11 @@
Command::Result::Redirection = Struct.new(:url)
class Command::Result::Redirection
attr_reader :url
def initialize(url)
@url = url
end
def as_json
{ redirect_to: url }
end
end
+1 -1
View File
@@ -18,9 +18,9 @@
action: "keydown.up->toggle-class#add:prevent keydown.up->navigable-list#selectCurrentOrLast terminal#hideError"
},
placeholder: "Press ⌘+K to search or type /commands…",
turbo_permanent: true,
spellcheck: "false" %>
<%= hidden_field_tag "confirmed", nil, data: { terminal_target: "confirmation" } %>
<%= hidden_field_tag "redirected", nil, data: { terminal_target: "redirected" } %>
<% end %>
-1
View File
@@ -9,7 +9,6 @@
terminal_output_class: "terminal--showing-output",
terminal_busy_class: "terminal--busy",
toggle_class_toggle_class: "terminal--open",
turbo_permanent: true,
action: "keydown->terminal#handleKeyPress keydown->navigable-list#navigate focusin->navigable-list#select keydown.esc->toggle-class#remove:stop keydown.esc->terminal#focus keydown.esc->navigable-list#selectLast keydown.esc@document->terminal#hideMenus" } do %>
<%= turbo_frame_tag :recent_commands, src: commands_path, refresh: "morph" %>
@@ -0,0 +1,6 @@
class AddParentColumnToCommands < ActiveRecord::Migration[8.1]
def change
add_reference :commands, :parent, null: true, foreign_key: { to_table: :commands }
add_index :commands, %i[ user_id parent_id created_at ]
end
end
Generated
+5 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_05_10_034936) do
ActiveRecord::Schema[8.1].define(version: 2025_05_15_125505) do
create_table "accesses", force: :cascade do |t|
t.integer "collection_id", null: false
t.datetime "created_at", null: false
@@ -163,10 +163,13 @@ ActiveRecord::Schema[8.1].define(version: 2025_05_10_034936) do
t.datetime "created_at", null: false
t.json "data", default: {}
t.text "line"
t.integer "parent_id"
t.string "type"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["parent_id"], name: "index_commands_on_parent_id"
t.index ["user_id", "created_at"], name: "index_commands_on_user_id_and_created_at"
t.index ["user_id", "parent_id", "created_at"], name: "index_commands_on_user_id_and_parent_id_and_created_at"
t.index ["user_id"], name: "index_commands_on_user_id"
end
@@ -342,6 +345,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_05_10_034936) do
add_foreign_key "closures", "cards"
add_foreign_key "closures", "users"
add_foreign_key "collections", "workflows"
add_foreign_key "commands", "commands", column: "parent_id"
add_foreign_key "commands", "users"
add_foreign_key "comments", "cards"
add_foreign_key "events", "collections"
+45 -1
View File
@@ -563,6 +563,16 @@ columns:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: parent_id
cast_type: *1
sql_type_metadata: *2
'null': true
default:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: type
@@ -1527,6 +1537,22 @@ indexes:
comment:
valid: true
commands:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: commands
name: index_commands_on_parent_id
unique: false
columns:
- parent_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: commands
name: index_commands_on_user_id
@@ -1560,6 +1586,24 @@ indexes:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: commands
name: index_commands_on_user_id_and_parent_id_and_created_at
unique: false
columns:
- user_id
- parent_id
- created_at
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
comments:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: comments
@@ -2117,4 +2161,4 @@ indexes:
comment:
valid: true
workflows: []
version: 20250510034936
version: 20250515125505
+33
View File
@@ -0,0 +1,33 @@
require "test_helper"
class Command::ChatQueryTest < ActionDispatch::IntegrationTest
include CommandTestHelper
setup do
Current.session = sessions(:david)
end
test "sandbox list of cards" do
user = users(:david)
url = cards_url
context = Command::Parser::Context.new(user, url: url)
puts Command::ChatQuery.new(user: user, context: context, query: "summarize cards about performance").execute
puts Command::ChatQuery.new(user: user, context: context, query: "tag with #performance").execute
puts Command::ChatQuery.new(user: user, context: context, query: "cards assigned to jorge").execute
puts Command::ChatQuery.new(user: user, context: context, query: "performance cards assigned to jorge").execute
puts Command::ChatQuery.new(user: user, context: context, query: "close performance cards assigned to jorge and tag them with #performance").execute
end
test "sandbox single card" do
user = users(:david)
url = card_url(cards(:logo))
context = Command::Parser::Context.new(user, url: url)
puts Command::ChatQuery.new(user: user, context: context, query: "summarize cards about performance").execute
puts Command::ChatQuery.new(user: user, context: context, query: "tag with performance and close").execute
puts Command::ChatQuery.new(user: user, context: context, query: "summarize this card").execute
puts Command::ChatQuery.new(user: user, context: context, query: "tag with #performance").execute
puts Command::ChatQuery.new(user: user, context: context, query: "cards assigned to jorge").execute
puts Command::ChatQuery.new(user: user, context: context, query: "performance cards assigned to jorge").execute
puts Command::ChatQuery.new(user: user, context: context, query: "close performance cards assigned to jorge and tag them with #performance").execute
end
end