Merge pull request #664 from basecamp/more-query-commands

Support more Fizzy Do commands
This commit is contained in:
Jorge Manrubia
2025-06-26 17:25:15 +02:00
committed by GitHub
26 changed files with 530706 additions and 60 deletions
+39
View File
@@ -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
+129 -50
View File
@@ -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 — creators 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 donts
* 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 DONTS ---------------------------
* 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 Andys 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 ---------------------------
+9 -2
View File
@@ -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
+2 -2
View File
@@ -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">
+2
View File
@@ -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>
+41
View File
@@ -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
+34 -6
View File
@@ -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
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
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