From 413dfce5aa4afdcca36e8c11e5f46ea6e1de84ae Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sat, 10 May 2025 11:17:35 +0200 Subject: [PATCH] PoC running commands --- app/controllers/commands_controller.rb | 2 +- .../controllers/terminal_controller.js | 25 ++++++- app/models/command/chat_query.rb | 69 ++++++++++++++++--- app/views/commands/index.html.erb | 2 +- 4 files changed, 84 insertions(+), 14 deletions(-) diff --git a/app/controllers/commands_controller.rb b/app/controllers/commands_controller.rb index f12e7673f..b697fb826 100644 --- a/app/controllers/commands_controller.rb +++ b/app/controllers/commands_controller.rb @@ -37,7 +37,7 @@ class CommandsController < ApplicationController when Command::Result::Redirection redirect_to result.url when Command::Result::ChatResponse - render turbo_stream: turbo_stream.append("chat-responses", partial: "commands/chat/response", locals: { content: result.content }) + render json: result.content, status: :accepted else redirect_back_or_to root_path end diff --git a/app/javascript/controllers/terminal_controller.js b/app/javascript/controllers/terminal_controller.js index 0e43293aa..324db9990 100644 --- a/app/javascript/controllers/terminal_controller.js +++ b/app/javascript/controllers/terminal_controller.js @@ -1,5 +1,6 @@ import { Controller } from "@hotwired/stimulus" import { HttpStatus } from "helpers/http_helpers" +import { delay } from "helpers/timing_helpers" export default class extends Controller { static targets = [ "input", "form", "confirmation" ] @@ -39,7 +40,15 @@ export default class extends Controller { handleCommandResponse(event) { if (event.detail.success) { - this.#reset() + const response = event.detail.fetchResponse; + if (response && response.response.headers.get("Content-Type")?.includes("application/json")) { + response.response.json().then((commands) => { + this.element.querySelector("#chat-responses").textContent = JSON.stringify(commands, null, 2) + this.#executeCommands(commands) + }); + } else { + this.#reset() + } } else { const response = event.detail.fetchResponse.response this.#handleErrorResponse(response) @@ -117,4 +126,18 @@ export default class extends Controller { this.confirmationTarget.value = "confirmed" this.formTarget.requestSubmit() } + + async #executeCommands(commands) { + for (const command of commands) { + if (command.command == "/search") { + const params = new URLSearchParams(command) + // Clunky example, for PoC purposes + Turbo.visit(`/cards?${params.toString()}`) + await delay(2000) + } else { + this.inputTarget.value = command.command + this.formTarget.requestSubmit() + } + } + } } diff --git a/app/models/command/chat_query.rb b/app/models/command/chat_query.rb index 74ba2650a..beef9c5eb 100644 --- a/app/models/command/chat_query.rb +++ b/app/models/command/chat_query.rb @@ -7,31 +7,78 @@ class Command::ChatQuery < Command def execute response = chat.ask query - Command::Result::ChatResponse.new(response.content) + Rails.logger.info "***\n#{response.content}\n***" + response = replace_names_with_ids(JSON.parse(response.content)) + Command::Result::ChatResponse.new(JSON.pretty_generate(response)) end private def chat chat = RubyLLM.chat - chat.with_instructions prompt + chat.with_instructions(prompt) end def prompt <<~PROMPT - You are a helpful assistant to translate natural language into commands that Fizzy understand, and to - answer questions about the Fizzy system and the information it contains. + You are a helpful assistant that translates natural language into commands that Fizzy understand, and that + can answer questions about the Fizzy system and the information it contains. Fizzy supports the following commands: - Assign users to cards: /assign user - - Close cards: /close - - Tag cards: /tag - - Search cards: /search query + - Close cards: /close optional reason + - Tag cards: /tag tag-name + - Search cards: /search. See how this works below: - You have to assume that the cards will be derived from the screen the user is at. But, if the user is - querying for certain cards, you can use the /search command to search for them./ + asks for a certain set of cards, you can use the /search command to filter. It supports the following + conditions: - The output must be simply a list of commands satisfying the user request, separated by newlines. - PROMPT + - assignment_status: can be "unassigned" + - indexed_by: can be "newest", "oldest", "latest", "stalled", "closed" + - engagement_status: can be "considering" or "doing" + - 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. + - terms: a list of terms to search for. Use this option to refine searches based on further keyword-based + queries. + + Please combine commands to satisfy what the user needs. E.g: search with keywords and filters and then apply + as many commands as needed. + + The output will be in JSON. It will contain a list of commands. Each command will be a JSON object like: + + { command: "/close" } + + For the case of the /search command, it will also contain the additional params. E.g: + + { command: "/search", indexed_by: "closed", collection_ids: [ "Writebook", "Design" ] } + + Notice that only /search commands carry additional JSON params. For /tag, /close and /assign just append the + param to the string command. + + Make sure to place into double quotes the strings in JSON values and that you generate valid JSON. I want a + JSON list like [{}, {}...] + 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 + end diff --git a/app/views/commands/index.html.erb b/app/views/commands/index.html.erb index 025b7c96b..7263ce09f 100644 --- a/app/views/commands/index.html.erb +++ b/app/views/commands/index.html.erb @@ -1,5 +1,5 @@ <%= turbo_frame_tag :recent_commands do %> - +