discourse/plugins/discourse-ai/lib/completions/open_ai_message_processor.rb
Sam 65024326dd
FEATURE: Add AI authoring to Discourse Workflows (#40504)
Previously, admins could only build Discourse Workflows by manually
adding and connecting every trigger, condition, and action node on the
canvas.

This change adds an AI authoring assistant, gated behind
`discourse_workflows_ai_authoring_enabled` and DiscourseAi, that turns a
natural-language request into a server-validated, reviewable workflow
patch the admin applies. It only ever proposes drafts and never
publishes.

---------

Co-authored-by: chapoi <101828855+chapoi@users.noreply.github.com>
Co-authored-by: Rafael Silva <xfalcox@gmail.com>
Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
2026-06-18 18:32:30 +02:00

138 lines
3.6 KiB
Ruby
Vendored

# frozen_string_literal: true
module DiscourseAi::Completions
class OpenAiMessageProcessor
attr_reader :prompt_tokens, :completion_tokens, :cache_read_tokens
def initialize(partial_tool_calls: false)
@tool = nil
@tool_arguments = +""
@prompt_tokens = nil
@completion_tokens = nil
@cache_read_tokens = nil
@partial_tool_calls = partial_tool_calls
end
def process_message(json)
result = []
tool_calls = json.dig(:choices, 0, :message, :tool_calls)
message = json.dig(:choices, 0, :message, :content)
result << message if message.present?
if tool_calls.present?
tool_calls.each do |tool_call|
id = tool_call.dig(:id)
name = tool_call.dig(:function, :name)
arguments = tool_call.dig(:function, :arguments)
parameters = ToolArgumentsParser.parse(arguments)
result << ToolCall.new(id: id, name: name, parameters: parameters)
end
end
update_usage(json)
result
end
def process_streamed_message(json)
rval = nil
tool_calls = json.dig(:choices, 0, :delta, :tool_calls)
content = json.dig(:choices, 0, :delta, :content)
finished_tools = json.dig(:choices, 0, :finish_reason) || tool_calls == []
if tool_calls.present?
id = tool_calls.dig(0, :id)
name = tool_calls.dig(0, :function, :name)
arguments = tool_calls.dig(0, :function, :arguments)
# TODO: multiple tool support may require index
#index = tool_calls[0].dig(:index)
if id.present? && @tool && @tool.id != id
process_arguments
@tool.partial = false
rval = @tool
@tool = nil
end
if id.present? && name.present?
@tool_arguments = +""
@tool = ToolCall.new(id: id, name: name)
@streaming_parser = JsonStreamingTracker.new(self) if @partial_tool_calls
end
@tool_arguments << arguments.to_s
@streaming_parser << arguments.to_s if @streaming_parser && !arguments.to_s.empty?
rval = current_tool_progress if !rval
elsif finished_tools && @tool
parsed_args = ToolArgumentsParser.parse(@tool_arguments)
@tool.parameters = parsed_args
@tool.partial = false
rval = @tool
@tool = nil
elsif !content.to_s.empty?
# we don't want to strip empty content like "\n", do not use present?
rval = content
end
update_usage(json)
rval
end
def notify_progress(key, value)
if @tool
@tool.partial = true
@tool.parameters[key.to_sym] = value
@has_new_data = true
end
end
def current_tool_progress
if @has_new_data
@has_new_data = false
@tool
else
nil
end
end
def finish
rval = []
if @tool
process_arguments
@tool.partial = false
rval << @tool
@tool = nil
end
rval
end
private
def process_arguments
if @tool_arguments.present?
parsed_args = ToolArgumentsParser.parse(@tool_arguments)
@tool.parameters = parsed_args
@tool_arguments = nil
end
end
def update_usage(json)
usage = json.dig(:usage)
return if !usage
cached_tokens = usage.dig(:prompt_tokens_details, :cached_tokens).to_i
prompt_tokens = usage[:prompt_tokens].to_i
completion_tokens = usage[:completion_tokens].to_i
@prompt_tokens = prompt_tokens - cached_tokens
@completion_tokens = completion_tokens if completion_tokens.positive?
@cache_read_tokens = cached_tokens
end
end
end