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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 Fizzy’s command translator. Given a request by the user you should:
|
||||
|
||||
1. Read the user’s 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
|
||||
@@ -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 Fizzy’s command translator. Read the user’s 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user