Merge pull request #664 from basecamp/more-query-commands
Support more Fizzy Do commands
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
class Command::AddCard < Command
|
||||
store_accessor :data, :card_title, :collection_id, :created_card_id
|
||||
|
||||
validates :collection, presence: true
|
||||
|
||||
def title
|
||||
"Create a new card with title '#{card_title}'"
|
||||
end
|
||||
|
||||
def execute
|
||||
transaction do
|
||||
card = collection.cards.create!(title: card_title)
|
||||
update! created_card_id: card.id
|
||||
|
||||
redirect_to collection_card_path(collection, card, focus_on_title: true)
|
||||
end
|
||||
end
|
||||
|
||||
def undo
|
||||
created_card&.destroy
|
||||
end
|
||||
|
||||
def undoable?
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
def closed_cards
|
||||
user.accessible_cards.where(id: closed_card_ids)
|
||||
end
|
||||
|
||||
def collection
|
||||
user.collections.find_by_id(collection_id)
|
||||
end
|
||||
|
||||
def created_card
|
||||
user.accessible_cards.find_by_id(created_card_id)
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,6 @@
|
||||
class Command::Ai::Translator
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
attr_reader :context
|
||||
|
||||
delegate :user, to: :context
|
||||
@@ -9,6 +11,7 @@ class Command::Ai::Translator
|
||||
|
||||
def translate(query)
|
||||
response = translate_query_with_llm(query)
|
||||
Rails.logger.info "AI Translate: #{query} => #{response}"
|
||||
normalize JSON.parse(response)
|
||||
end
|
||||
|
||||
@@ -23,7 +26,7 @@ class Command::Ai::Translator
|
||||
end
|
||||
|
||||
def chat
|
||||
chat = ::RubyLLM.chat
|
||||
chat = RubyLLM.chat.with_temperature(0)
|
||||
chat.with_instructions(prompt + custom_context)
|
||||
end
|
||||
|
||||
@@ -70,58 +73,110 @@ class Command::Ai::Translator
|
||||
Cards have comments and live inside collections.
|
||||
|
||||
Context filters describe card state already true.
|
||||
Commands (/assign, /tag, /close, /search, /clear, /do, /reconsider, /consider, /stage) apply new actions.
|
||||
Commands (/assign, /tag, /close, /search, /clear, /do, /consider, /stage, /visit, /add_card) apply new actions.
|
||||
|
||||
Context properties you may use
|
||||
* terms — array of keywords
|
||||
* indexed_by — "newest", "oldest", "latest", "stalled", "closed"
|
||||
* assignee_ids — array of assignee names
|
||||
* assignment_status — "unassigned"
|
||||
* assignment_status — "unassigned". Important: ONLY when the user asks for unassigned cards.
|
||||
* card_ids — array of card IDs
|
||||
* creator_id — creator’s name
|
||||
* collection_ids — array of collections
|
||||
* tag_ids — array of tag names
|
||||
|
||||
Explicit filtering rules
|
||||
* Use terms only if the query explicitly refers to cards; plain-text searches go to /search.
|
||||
* Numbers without the word "card(s)" default to terms.
|
||||
– "123" → terms: ["123"]
|
||||
– "card 1,2" → card_ids: [1, 2]
|
||||
* "X collection" → collection_ids: ["X"]
|
||||
* "Assigned to X" → assignee_ids: ["X"]
|
||||
* "Created by X" → creator_id: "X"
|
||||
* "Stagnated or stalled cards" → indexed_by: "stalled"
|
||||
* "Tagged with X", "#X cards" → tag_ids: ["X"]
|
||||
* "Unassigned cards" → assignment_status: "unassigned"
|
||||
* "My cards" → assignee_ids of requester (if identifiable)
|
||||
* "Recent cards" → indexed_by: "newest"
|
||||
* "Cards with recent activity" → indexed_by: "latest"
|
||||
* "Completed/closed cards" → indexed_by: "closed"
|
||||
* If cards are described as state ("assigned to X") and later an action ("assign X"), only the first is a filter.
|
||||
---------------------- EXPLICIT FILTERING RULES ----------------------
|
||||
|
||||
Command interpretation rules
|
||||
* /do → engage with card and move it to "doing"
|
||||
* /reconsider or /consider → move card back to "considering"
|
||||
* Unless a clear command applies, fallback to /search with the verbatim text.
|
||||
* When searching for nouns (non-person), prefer /search over terms.
|
||||
* Respect the spoken order of commands.
|
||||
* "tag with #design" → /tag #design (not a filter)
|
||||
* "#design cards" → context.tag_ids = ["design"] (no /tag)
|
||||
* "Assign cards tagged with #design to jz"
|
||||
→ context.tag_ids = ["design"]; command /assign jz
|
||||
* "close as [reason]" or "close because [reason]" → /close [reason]
|
||||
* Lone "close" → /close (acts on current context)
|
||||
* /stage [workflow stage] → assign the card to the given stage. The card is in a stage when it gets assigned a stage.
|
||||
* When asking to *move* a card, unless it refers to "do, doing, reconsider, considering", use /stage.
|
||||
* Use terms only if the query explicitly refers to cards; plain-text searches go to /search.
|
||||
* Numbers without the word "card(s)" default to terms **unless the number is the direct object of an
|
||||
action verb that operates on cards (move, assign, tag, close, stage, consider, do, etc.).**
|
||||
– "123" → terms: ["123"]
|
||||
– "card 123" → card_id: ["123"]
|
||||
– "card 1,2" → card_ids: [1, 2]
|
||||
– "move 1 and 2 to doing" → context.card_ids = [1, 2]; command /do
|
||||
– "123" (with no action verb) → terms: ["123"]
|
||||
* Quick mnemonic
|
||||
WORD “card(s)” present? → card_ids
|
||||
ACTION verb present? → card_ids + command
|
||||
Otherwise → terms
|
||||
* "X collection" → collection_ids: ["X"]
|
||||
* **Past-tense** “assigned to X” → assignee_ids: ["X"] (filter)
|
||||
* **Imperative** “assign to X”, “assign to me” → command /assign X
|
||||
– Never use assignee_ids when the user gives an imperative assignment
|
||||
* "Created by X" → creator_id: "X"
|
||||
* "Stagnated or stalled cards" → indexed_by: "stalled"
|
||||
* **Past-tense** “tagged with #X”, “#X cards” → tag_ids: ["X"] (filter)
|
||||
* **Imperative** “tag …”, “tag with #X”, “add the #X tag”, “apply #X”
|
||||
→ command /tag #X (never a filter)
|
||||
* "Unassigned cards" (or “not assigned”, “with no assignee”)
|
||||
→ assignment_status: "unassigned".
|
||||
– IMPORTANT: Only set assignment_status when the user **explicitly** asks for an unassigned state
|
||||
– Do NOT infer unassigned just because an assignment follows
|
||||
– “Assign to David” → /assign david (do NOT include assignment_status)
|
||||
* "My cards" → assignee_ids of requester (if identifiable)
|
||||
* “Recent cards” (i.e., newly created) → indexed_by: "newest"
|
||||
* “Cards with recent activity”, “recently updated cards” → indexed_by: "latest"
|
||||
– Only use "latest" if the user mentions activity, updates, or changes
|
||||
– Otherwise, prefer "newest" for generic mentions of “recent”
|
||||
* "Completed/closed cards" → indexed_by: "closed"
|
||||
* If cards are described as state ("assigned to X") and later an action ("assign X"), only the first is a filter.
|
||||
* ❗ Once you produce a valid context **or** command list, do not add a fallback /search.
|
||||
|
||||
Crucial don’ts
|
||||
* Never use names or tags mentioned inside commands as filters.
|
||||
* Never add properties tied to UI view ("card", "list", etc.).
|
||||
* All filters, including terms, must live inside context.
|
||||
* Do not duplicate terms across properties.
|
||||
* Avoid redundant terms.
|
||||
-------------------- COMMAND INTERPRETATION RULES --------------------
|
||||
|
||||
Positive & negative examples
|
||||
* /do → engage with card and move it to "doing"
|
||||
* /consider → move card back to "considering" (reconsider)
|
||||
* Unless a clear command applies, fallback to /search with the verbatim text.
|
||||
* When searching for nouns (non-person), prefer /search over terms.
|
||||
* Respect the spoken order of commands.
|
||||
* "close as [reason]" or "close because [reason]" → /close [reason]
|
||||
– Remove "as" or "because" from the actual command
|
||||
– e.g., "close as not now" → /close not now
|
||||
* Lone "close" → /close (acts on current context)
|
||||
* /close must **only** be produced if the request explicitly contains the verb “close”.
|
||||
* /visit [url or path] lets you visit arbitrary URLs and paths. E.g: /visit /cards/123
|
||||
* /stage [workflow stage] → assign the card to the given stage
|
||||
– /stage never takes card IDs as arguments.
|
||||
* “Move <ID(s)> to doing” → context.card_ids = [IDs]; command /do
|
||||
* “Move <ID(s)> to considering” → context.card_ids = [IDs]; command /consider
|
||||
* “Move <ID(s)> to <Stage>” → context.card_ids = [IDs]; command /stage <Stage>
|
||||
* /add_card → Create a new card with a blank title
|
||||
* /add_card [title] → Create a new card with the provided title
|
||||
|
||||
---------------------------- VISIT SCREENS ---------------------------
|
||||
|
||||
You can open these screens by using /visits with their urls:
|
||||
|
||||
* My profile → /visit #{user_path(user)}
|
||||
* Edit my profile (including your name and avatar) → /visit #{edit_user_path(user)}
|
||||
* Manage users → /visit #{account_settings_path}
|
||||
* Account settings → /visit #{account_settings_path}
|
||||
|
||||
---------------------------- CRUCIAL DON’TS ---------------------------
|
||||
|
||||
* Never use names, tags, or stage names mentioned **inside commands** (like /assign, /tag, /stage) as filters.
|
||||
– e.g., “assign to jason” → only /assign jason (NOT assignee_ids)
|
||||
– e.g., “set the stage to Investigating” → only /stage Investigating (NOT terms)
|
||||
* Never duplicate the assignee in both `commands` and `context`.
|
||||
– If the request says “assign to X”, produce only `/assign X`, never assignee_ids
|
||||
* Never add properties tied to UI view ("card", "list", etc.).
|
||||
* When you see a word with a # prefix, assume it refers to a tag (either a filter or a command argument, but don't search for it').
|
||||
* All filters, including terms, must live **inside** context.
|
||||
* Do not duplicate terms across properties.
|
||||
* Avoid redundant terms.
|
||||
|
||||
---------------------------- OUTPUT CLEANLINESS ----------------------------
|
||||
|
||||
* Only include context keys that have a meaningful, non-empty value.
|
||||
– Do NOT include empty arrays (e.g., [], []).
|
||||
– Do NOT include empty strings ("") or default values that don't apply.
|
||||
– Do NOT emit unused or null context keys — omit them entirely.
|
||||
– Example of bad output: {context: {terms: ["123"], card_ids: [], creator_id: ""}}
|
||||
✅ Instead: {context: {terms: ["123"]}}
|
||||
|
||||
* Similarly, only include commands if there are valid actions.
|
||||
|
||||
---------------------- POSITIVE & NEGATIVE EXAMPLES -------------------
|
||||
|
||||
User: assign andy to the current #design cards assigned to jz and tag them with #v2
|
||||
Output:
|
||||
@@ -130,20 +185,44 @@ class Command::Ai::Translator
|
||||
"commands": ["/assign andy", "/tag #v2"]
|
||||
}
|
||||
|
||||
Incorrect (do NOT do this):
|
||||
User: assign to jz
|
||||
Output:
|
||||
{
|
||||
"context": { "assignee_ids": ["andy"], "tag_ids": ["v2"] },
|
||||
"commands": ["/assign andy", "/tag #v2"]
|
||||
"commands": ["/assign jz"]
|
||||
}
|
||||
|
||||
Additional examples:
|
||||
{ "context": { "assignee_ids": ["jorge"] }, "commands": ["/close"] }
|
||||
{ "context": { "tag_ids": ["design"] } }
|
||||
{ "commands": ["/assign jorge", "/tag #design"] }
|
||||
{ "commands": ["/do"] }
|
||||
{ "commands": ["/reconsider"] }
|
||||
User: cards assigned to jz
|
||||
Output:
|
||||
{
|
||||
"context": { "assignee_ids": ["jz"] }
|
||||
}
|
||||
|
||||
Fallback search example:
|
||||
User: tag with #design
|
||||
Output:
|
||||
{
|
||||
"commands": ["/tag #design"]
|
||||
}
|
||||
|
||||
User: "cards tagged with #design" or "#design cards"
|
||||
Output:
|
||||
{
|
||||
"context": { "tag_ids": ["design"] }
|
||||
}
|
||||
|
||||
User: Unassigned cards
|
||||
Output:
|
||||
{
|
||||
"context": { "assignment_status": "unassigned" }
|
||||
}
|
||||
|
||||
User: Close Andy’s cards, then assign them to Kevin
|
||||
Output:
|
||||
{
|
||||
"context": { "assignee_ids": ["andy"] },
|
||||
"commands": ["/close", "/assign kevin"]
|
||||
}
|
||||
|
||||
Fallback search example (when nothing matches):
|
||||
{ "commands": ["/search what's blocking deploy"] }
|
||||
|
||||
---------------------------- END OF PROMPT ---------------------------
|
||||
|
||||
@@ -37,6 +37,8 @@ class Command::Parser
|
||||
Command::Do.new(card_ids: cards.ids)
|
||||
when "/insight"
|
||||
Command::GetInsight.new(query: combined_arguments, card_ids: cards.ids)
|
||||
when "/add_card"
|
||||
Command::AddCard.new(card_title: combined_arguments, collection_id: guess_collection&.id)
|
||||
when "/search"
|
||||
Command::Search.new(terms: combined_arguments)
|
||||
when "/stage"
|
||||
@@ -70,6 +72,10 @@ class Command::Parser
|
||||
end
|
||||
end
|
||||
|
||||
def guess_collection
|
||||
cards.first&.collection || Collection.first
|
||||
end
|
||||
|
||||
def candidate_stages
|
||||
Workflow::Stage.where(workflow_id: cards.joins(:collection).select("collections.workflow_id").distinct)
|
||||
end
|
||||
@@ -89,8 +95,9 @@ class Command::Parser
|
||||
end
|
||||
|
||||
def multiple_cards_from(string)
|
||||
if tokens = string.split(/[\s,]+/).filter { it =~ /\A\d+\z/ }.presence
|
||||
user.accessible_cards.where(id: tokens).presence if tokens.many?
|
||||
if string.match?(/^[\d\s,]+$/)
|
||||
tokens = string.split(/[\s,]+/)
|
||||
user.accessible_cards.where(id: tokens).presence if tokens&.many?
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<% if card.published? %>
|
||||
<% if card.published? && !params[:focus_on_title]%>
|
||||
<%= turbo_frame_tag card, :edit do %>
|
||||
<div class="card__content">
|
||||
<h1 class="card__title flex align-start gap-half">
|
||||
@@ -14,7 +14,7 @@
|
||||
<h1 class="card__title autoresize__wrapper" data-autoresize-target="wrapper" data-autoresize-clone-value="">
|
||||
<%= form.text_area :title, placeholder: "Name it…",
|
||||
class: "input input--textarea full-width borderless txt-align-start autoresize__textarea",
|
||||
autofocus: card.title.blank?, rows: 1,
|
||||
autofocus: card.title.blank? || params[:focus_on_title], rows: 1,
|
||||
data: { autoresize_target: "textarea", action: "input->autoresize#resize auto-save#change blur->auto-save#submit keydown.enter->auto-save#submit:prevent keydown.ctrl+enter->auto-save#submit:prevent" } %>
|
||||
</h1>
|
||||
<div class="card__description rich-text-content margin-block-start-half margin-block-end">
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<dd>Clear all filters</dd>
|
||||
|
||||
<h3>Cards</h3>
|
||||
<dt><code>/add_card [title]</code></dt>
|
||||
<dd>Create a new card</dd>
|
||||
<dt><code>/assign [person] [person]…</code></dt>
|
||||
<dd>Assign visible card(s)</dd>
|
||||
<dt><code>/close [reason]</code></dt>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
require "test_helper"
|
||||
|
||||
class Command::AddCardTest < ActionDispatch::IntegrationTest
|
||||
include CommandTestHelper
|
||||
|
||||
setup do
|
||||
Current.session = sessions(:david)
|
||||
@card = cards(:text)
|
||||
end
|
||||
|
||||
test "create a new untitled card" do
|
||||
result = assert_difference -> { users(:david).accessible_cards.count }, +1 do
|
||||
execute_command "/add_card", context_url: collection_card_url(@card.collection, @card)
|
||||
end
|
||||
|
||||
new_card = users(:david).accessible_cards.last
|
||||
assert_equal "", new_card.title
|
||||
assert_equal @card.collection, new_card.collection
|
||||
assert_equal collection_card_path(new_card.collection, new_card, focus_on_title: true), result.url
|
||||
end
|
||||
|
||||
test "create a new titled card" do
|
||||
result = assert_difference -> { users(:david).accessible_cards.count }, +1 do
|
||||
execute_command "/add_card Review report", context_url: collection_card_url(@card.collection, @card)
|
||||
end
|
||||
|
||||
new_card = users(:david).accessible_cards.last
|
||||
assert_equal "Review report", new_card.title
|
||||
assert_equal @card.collection, new_card.collection
|
||||
assert_equal collection_card_path(new_card.collection, new_card, focus_on_title: true), result.url
|
||||
end
|
||||
|
||||
test "undo card creation" do
|
||||
command = parse_command "/add_card", context_url: collection_cards_url(@card.collection)
|
||||
command.execute
|
||||
|
||||
assert_difference -> { users(:david).accessible_cards.count }, -1 do
|
||||
command.undo
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,7 @@ class Command::Ai::TranslatorTest < ActionDispatch::IntegrationTest
|
||||
include VcrTestHelper
|
||||
|
||||
setup do
|
||||
@user= users(:david)
|
||||
@user = users(:david)
|
||||
end
|
||||
|
||||
test "filter by assignments" do
|
||||
@@ -66,7 +66,7 @@ class Command::Ai::TranslatorTest < ActionDispatch::IntegrationTest
|
||||
test "assign cards" do
|
||||
# List context
|
||||
assert_command({ commands: [ "/assign jz" ] }, "assign to jz")
|
||||
assert_command({ context: { tag_ids: [ "design" ] }, commands: [ "/assign jz" ] }, "assign cards agged with #design to jz", context: :card)
|
||||
assert_command({ context: { tag_ids: [ "design" ] }, commands: [ "/assign jz" ] }, "assign cards tagged with #design to jz", context: :card)
|
||||
end
|
||||
|
||||
test "tag cards" do
|
||||
@@ -78,7 +78,7 @@ class Command::Ai::TranslatorTest < ActionDispatch::IntegrationTest
|
||||
assert_command({ commands: [ "/consider" ] }, "consider")
|
||||
assert_command({ commands: [ "/consider" ] }, "move to consider")
|
||||
|
||||
assert_command({ commands: [ "/do" ] }, "doing")
|
||||
assert_command({ commands: [ "/do" ] }, "do")
|
||||
assert_command({ commands: [ "/do" ] }, "move to doing")
|
||||
end
|
||||
|
||||
@@ -88,9 +88,37 @@ class Command::Ai::TranslatorTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "combine commands and filters" do
|
||||
assert_command({ context: { assignee_ids: [ "jz" ], tag_ids: [ "design" ] }, commands: [ "/assign andy", "/tag #v2" ] }, "assign andy to the current #design cards assigned to jz and tag them with #v2")
|
||||
assert_command({ context: { assignee_ids: [ "andy" ] }, commands: [ "/close", "/assign kevin" ] }, "close cards assigned to andy and assign them to kevin")
|
||||
assert_command({ context: { tag_ids: [ "design" ], assignee_ids: [ "jz" ] }, commands: [ "/assign andy", "/tag #v2" ] }, "assign cards tagged with #design assigned to jz to andy and tag them with #v2")
|
||||
assert_command(
|
||||
{ context: { card_ids: [ 176, 170 ] }, commands: [ "/do", "/assign david", "/stage Investigating" ] },
|
||||
"Move 176 and 170 to doing, assign to me and set the stage to Investigating")
|
||||
assert_command(
|
||||
{ context: { assignee_ids: [ "jz" ], tag_ids: [ "design" ] }, commands: [ "/assign andy", "/tag #v2" ] },
|
||||
"assign andy to the current #design cards assigned to jz and tag them with #v2")
|
||||
assert_command(
|
||||
{ context: { assignee_ids: [ "andy" ] }, commands: [ "/close", "/assign kevin" ] },
|
||||
"close cards assigned to andy and assign them to kevin")
|
||||
assert_command(
|
||||
{ context: { tag_ids: [ "design" ], assignee_ids: [ "jz" ] }, commands: [ "/assign andy", "/tag #v2" ] },
|
||||
"assign cards tagged with #design assigned to jz to andy and tag them with #v2")
|
||||
end
|
||||
|
||||
test "default to search" do
|
||||
assert_command({ commands: [ "/search backups" ] }, "backups")
|
||||
assert_command({ context: { terms: [ "backups" ] } }, "cards about backups")
|
||||
end
|
||||
|
||||
test "visit screens" do
|
||||
assert_command({ commands: [ "/visit #{user_path(@user)}" ] }, "my profile")
|
||||
assert_command({ commands: [ "/visit #{edit_user_path(@user)}" ] }, "edit my profile")
|
||||
assert_command({ commands: [ "/visit #{account_settings_path}" ] }, "manage users")
|
||||
assert_command({ commands: [ "/visit #{account_settings_path}" ] }, "account settings")
|
||||
end
|
||||
|
||||
test "create cards" do
|
||||
assert_command({ commands: [ "/add_card" ] }, "add card")
|
||||
assert_command({ commands: [ "/add_card new task" ] }, "add card new task")
|
||||
assert_command({ commands: [ "/add_card" ] }, "create card")
|
||||
assert_command({ commands: [ "/add_card urgent issue" ] }, "create card urgent issue")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
+8917
File diff suppressed because it is too large
Load Diff
+9015
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+43105
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+47713
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+8887
File diff suppressed because it is too large
Load Diff
+8893
File diff suppressed because it is too large
Load Diff
+8397
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user