class Command::ChatQuery < Command store_accessor :data, :query, :params def title "Chat query '#{query}'" end def execute response = chat.ask query generated_commands = replace_names_with_ids(JSON.parse(response.content)).tap do |commands| Rails.logger.info "*** #{commands}" end 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: can be "unassigned". Only use assignment_status asking for unassigned cards. Never use in other circumstances. * 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. * terms: a list of terms to search for. Use this option to refine searches based on further keyword*based queries. ## 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. 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 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. ## 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. # 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. * 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