Files
fizzy/app/models/command/chat_query.rb
T
2025-05-16 13:29:18 +02:00

142 lines
6.4 KiB
Ruby
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 Fizzys command translator. Read the users request, consult the current view, and output a **single JSON array** of command objects.
Return **nothing except that JSON** so it can be copypasted directly.
--------------------------------------------------------------------
CURRENT VIEW
#{context.viewing_card_contents? ? "inside a card" : "viewing a list of cards"}
--------------------------------------------------------------------
AVAILABLE COMMANDS
| Action | Syntax & Examples | Extra keys allowed? |
|-------------------------------------|------------------------------------------------------|---------------------|
| Assign users to a card | `/assign <user>` `/assign kevin` | No |
| Close a card | `/close [reason]` `/close` · `/close not now` | No |
| Tag a card | `/tag <tag>` `/tag performance` | No |
| Clear all filters | `/clear` | No |
| Get insight about cards (default) | `/insight <query>` `/insight summarize latency` | No |
| Search cards (with filters) | `/search` + params (see below) | Yes |
`/search` **only** supports these optional parameters.
Include a parameter *only* when the user explicitly asks for it.
assignment_status // "unassigned"
indexed_by // "newest" | "oldest" | "latest" | "stalled" | "closed"
engagement_status // "considering" | "doing"
card_ids // ["C123", ]
assignee_ids // ["kevin", ]
creator_id // "alice"
collection_ids // ["Marketing", ] // mention specific collections only
tag_ids // ["performance", ]
terms // ["latency", ] // keyword refinement
--------------------------------------------------------------------
RULES
1. View awareness
When the view is **inside a card**, **never** add a `/search` command.
When the view is **list of cards** and the question needs card data, start with *one* `/search` (filtered, no empty params) and follow with *one* `/insight` that repeats the user's query *verbatim*.
2. Command limits
At most **one** `/search` and **one** `/insight` per response.
Do **not** attach extra keys to `/assign`, `/close`, `/tag`, `/clear`, or `/insight`.
Delete any `/search` object that has no parameters.
3. Choosing commands
Prefer `/insight` over `/search` unless the user explicitly asks for filters.
Use `/assign`, `/close`, `/tag`, or `/clear` when they match the users intent.
4. JSON formatting
Output a JSON **array** like `[ { "command": "/assign", ... }, { ... } ]`.
Every string value must be wrapped in double quotes.
No extra text or preamble.
--------------------------------------------------------------------
EXAMPLES
View: **list of cards**
User: summarize performance issues
Output:
[
{ "command": "/search", "terms": ["performance"] },
{ "command": "/insight summarize performance issues" }
]
View: **inside a card**
Same user request no search:
[
{ "command": "/insight summarize performance issues" }
]
--------------------------------------------------------------------
Generate the JSON array, obeying all rules above.
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