Better foundation for chat responses

This commit is contained in:
Jorge Manrubia
2025-05-12 21:30:06 +02:00
parent 5425b03af4
commit cd53ef59eb
9 changed files with 131 additions and 71 deletions
+28 -3
View File
@@ -12,7 +12,7 @@ class CommandsController < ApplicationController
result = command.execute
respond_with_execution_result(result)
else
render plain: command.title, status: :conflict
respond_with_needs_confirmation(command)
end
else
head :unprocessable_entity
@@ -21,7 +21,11 @@ class CommandsController < ApplicationController
private
def parse_command(string)
Command::Parser.new(parsing_context).parse(string)
command_parser.parse(string)
end
def command_parser
@command_parser ||= Command::Parser.new(parsing_context)
end
def parsing_context
@@ -37,9 +41,30 @@ class CommandsController < ApplicationController
when Command::Result::Redirection
redirect_to result.url
when Command::Result::ChatResponse
render json: result.content, status: :accepted
respond_with_chat_response(result)
else
redirect_back_or_to root_path
end
end
def respond_with_chat_response(result)
command = chat_response_to_command(result)
if confirmed?(command)
command.execute
redirect_back_or_to root_path
else
respond_with_needs_confirmation(command.commands, redirect_to: result.context_url)
end
end
def respond_with_needs_confirmation(commands, redirect_to: nil)
render json: { commands: Array(commands).collect(&:title), redirect_to: redirect_to }, status: :conflict
end
def chat_response_to_command(chat_response)
context = Command::Parser::Context.new(Current.user, url: chat_response.context_url || request.referrer)
parser = Command::Parser.new(context)
Command::Composite.new(chat_response.command_lines.collect { parser.parse it })
end
end
@@ -1,14 +1,18 @@
import { Controller } from "@hotwired/stimulus"
import { HttpStatus } from "helpers/http_helpers"
import { delay } from "helpers/timing_helpers"
import { delay, nextFrame } from "helpers/timing_helpers"
import { marked } from "marked"
export default class extends Controller {
static targets = [ "input", "form", "confirmation" ]
static classes = [ "error", "confirmation", "help" ]
static values = { originalInput: String, waitingForConfirmation: Boolean }
disconnect() {
if (this.waitingForConfirmation) { this.#reset() }
connect() {
if (this.waitingForConfirmationValue) {
this.inputTarget.setSelectionRange(this.inputTarget.value.length, this.inputTarget.value.length)
this.inputTarget.focus()
}
}
// Actions
@@ -33,7 +37,7 @@ export default class extends Controller {
}
handleKeyPress(event) {
if (this.waitingForConfirmation) {
if (this.waitingForConfirmationValue) {
this.#handleConfirmationKey(event.key.toLowerCase())
event.preventDefault()
}
@@ -41,12 +45,7 @@ export default class extends Controller {
handleCommandResponse(event) {
if (event.detail.success) {
const response = event.detail.fetchResponse
if (response && response.response.headers.get("Content-Type")?.includes("application/json")) {
this.#handleJsonCommandResponse(response)
} else {
this.#reset()
}
this.#reset()
} else {
const response = event.detail.fetchResponse.response
this.#handleErrorResponse(response)
@@ -79,31 +78,20 @@ export default class extends Controller {
async #handleErrorResponse(response) {
const status = response.status
const message = await response.text()
if (status === HttpStatus.UNPROCESSABLE) {
this.#showError()
} else if (status === HttpStatus.CONFLICT) {
this.#requestConfirmation(message)
const jsonResponse = await response.json()
this.#requestConfirmation(jsonResponse)
}
}
#handleJsonCommandResponse(response) {
response.response.json().then((commands) => {
if (commands.reply) {
this.element.querySelector("#chat-insight").innerHTML = marked.parse(commands.reply)
} else {
this.element.querySelector("#chat-responses").textContent = JSON.stringify(commands, null, 2)
}
this.#executeCommands(commands)
})
}
#reset(inputValue = "") {
this.formTarget.reset()
console.debug("RESET!")
this.inputTarget.value = inputValue
this.confirmationTarget.value = ""
this.waitingForConfirmation = false
this.waitingForConfirmationValue = false
this.originalInputValue = null
this.element.classList.remove(this.errorClass)
@@ -114,12 +102,20 @@ export default class extends Controller {
this.element.classList.add(this.errorClass)
}
async #requestConfirmation(message) {
async #requestConfirmation(jsonResponse) {
const message = jsonResponse.commands[0]
console.debug("request confirmation", this.inputTarget.value);
this.originalInputValue = this.inputTarget.value
this.element.classList.add(this.confirmationClass)
this.inputTarget.value = `${message}? [Y/n] `
this.waitingForConfirmation = true
this.waitingForConfirmationValue = true
if (jsonResponse.redirect_to) {
Turbo.visit(jsonResponse.redirect_to, { frame: "cards" })
}
}
#handleConfirmationKey(key) {
@@ -135,32 +131,4 @@ export default class extends Controller {
this.confirmationTarget.value = "confirmed"
this.formTarget.requestSubmit()
}
async #executeCommands(commands) {
for (const command of commands) {
if (command.command == "/search") {
// Clunky example, for PoC purposes
Turbo.visit(`/cards?${toQueryString(command)}`)
await delay(2000)
} else {
this.inputTarget.value = command.command
this.formTarget.requestSubmit()
}
}
}
}
function toQueryString(obj) {
const params = new URLSearchParams()
for (const key in obj) {
const value = obj[key]
if (Array.isArray(value)) {
for (const item of value) {
params.append(`${key}[]`, item)
}
} else if (value !== undefined && value !== null) {
params.append(key, value)
}
}
return params.toString()
}
+22 -3
View File
@@ -7,8 +7,8 @@ class Command::ChatQuery < Command
def execute
response = chat.ask query
response = replace_names_with_ids(JSON.parse(response.content))
Command::Result::ChatResponse.new(JSON.pretty_generate(response))
generated_commands = replace_names_with_ids(JSON.parse(response.content))
build_chat_response_with generated_commands
end
private
@@ -17,6 +17,8 @@ class Command::ChatQuery < Command
chat.with_instructions(prompt)
end
# TODO:
# - Don't generate initial /search if not requested. "Assign to JZ" should
def prompt
<<~PROMPT
You are a helpful assistant that translates natural language into commands that Fizzy understand.
@@ -76,7 +78,9 @@ class Command::ChatQuery < Command
]
Unless asking for explicit filtering, always prefer /insight over /search. When asking about "cards" with properties that can
be satisfied with /search, then use /search
be satisfied with /search, then use /search.
A response can contain at most one /search command.
Please combine commands to satisfy what the user needs. E.g: search with keywords and filters and then apply
as many commands as needed. Make sure you don't leave actions mentioned in the query needs unattended.'
@@ -111,4 +115,19 @@ class Command::ChatQuery < Command
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)
generated_commands.filter { it["command"] != "/search" }.collect { it["command"] }
end
def response_context_url_from(generated_commands)
if search_command = generated_commands.find { it["command"] == "/search" }
cards_path(**search_command.without("command"))
end
end
end
+33
View File
@@ -0,0 +1,33 @@
# A composite of commands
class Command::Composite
attr_reader :commands
def initialize(commands)
@commands = commands
end
def title
@commands.collect(&:title).join(", ")
end
def execute
ApplicationRecord.transaction do
commands.each(&:execute)
end
end
def undo
ApplicationRecord.transaction do
undoable_commands.reverse.each(&:undo)
end
end
def needs_confirmation?
commands.any?(&:needs_confirmation?)
end
private
def undoable_commands
commands.filter(&:undoable?)
end
end
+1 -1
View File
@@ -9,7 +9,7 @@ class Command::GetInsight < Command
def execute
response = chat.ask query
Command::Result::ChatResponse.new({ reply: response.content })
Command::Result::InsightResponse.new({ reply: response.content })
end
def undoable?
+12 -1
View File
@@ -1 +1,12 @@
Command::Result::ChatResponse = Struct.new(:content)
class Command::Result::ChatResponse
attr_reader :command_lines, :context_url
def initialize(context_url: nil, command_lines:)
@context_url = context_url
@command_lines = command_lines
end
def has_context_url?
context_url.present?
end
end
@@ -0,0 +1 @@
Command::Result::InsightResponse = Struct.new(:content)
-1
View File
@@ -4,7 +4,6 @@
data: {
controller: "form",
terminal_target: "form",
turbo_permanent: true,
action: "turbo:submit-end->terminal#focus keydown.meta+k@document->terminal#focus keydown.enter->terminal#executeCommand keydown.esc->terminal#hideHelpMenu turbo:submit-end->terminal#handleCommandResponse"
} do %>
<label class="terminal__label txt-nowrap" for="fizzy_do">Fizzy do &gt;</label>
+11 -7
View File
@@ -1,10 +1,14 @@
<%= tag.div class: "terminal full-width flex flex-column gap justify-end", data: {
controller: "toggle-class terminal navigable-list",
terminal_error_class: "terminal--error",
terminal_confirmation_class: "terminal--confirmation",
terminal_help_class: "terminal--showing-help",
toggle_class_toggle_class: "terminal--open",
action: "keydown->terminal#handleKeyPress keydown->navigable-list#navigate focusin->navigable-list#select keydown.esc->toggle-class#remove:stop keydown.esc->terminal#focus keydown.esc->navigable-list#selectLast keydown.esc@document->terminal#hideHelpMenu" } do %>
<%= tag.div \
id: "command-terminal",
class: "terminal full-width flex flex-column gap justify-end",
data: {
controller: "toggle-class terminal navigable-list",
terminal_error_class: "terminal--error",
terminal_confirmation_class: "terminal--confirmation",
terminal_help_class: "terminal--showing-help",
toggle_class_toggle_class: "terminal--open",
turbo_permanent: true,
action: "keydown->terminal#handleKeyPress keydown->navigable-list#navigate focusin->navigable-list#select keydown.esc->toggle-class#remove:stop keydown.esc->terminal#focus keydown.esc->navigable-list#selectLast keydown.esc@document->terminal#hideHelpMenu" } do %>
<%= turbo_frame_tag :recent_commands, src: commands_path, refresh: "morph" %>
<%= render "commands/form" %>