discourse/plugins/discourse-ai/evals/lib/runners/translation.rb
Natalie Tay 71443be51a
DEV: Update post translation prompts for german cases (#40112)
Two production bugs in the post raw translator, both surfacing on
German:

1. BBCode attribute substitution [quote="user, post:N, topic:M"] was
being rewritten as Beitrag:/Thema:, breaking the quote link
2. mid translation truncation due to `"` as qwen uses structured output
({"output": "..."}) and wrote a plain `"` for the closing quote in
`„flach"`, terminating the JSON output string.

https://github.com/discourse/discourse-ai-evals/pull/17
2026-05-18 20:31:13 +08:00

126 lines
4.1 KiB
Ruby
Vendored

# frozen_string_literal: true
require_relative "base"
module DiscourseAi
module Evals
module Runners
class Translation < Base
OPERATIONS = {
"locale_detector" => DiscourseAi::Agents::LocaleDetector,
"post_raw_translator" => DiscourseAi::Agents::PostRawTranslator,
"topic_title_translator" => DiscourseAi::Agents::TopicTitleTranslator,
"short_text_translator" => DiscourseAi::Agents::ShortTextTranslator,
}.freeze
def self.can_handle?(feature_name)
feature_name&.start_with?("translation:")
end
def initialize(feature_name, agent_prompt_override = nil)
super
@operation = feature_name
if !OPERATIONS.key?(@operation)
raise ArgumentError, "Unsupported translation feature '#{feature_name}'"
end
end
def run(eval_case, llm, execution_context:)
raw_args = eval_case.args
if raw_args.present? && !raw_args.is_a?(Hash)
raise ArgumentError, "Translation evals expect args defined as a Hash"
end
args = (raw_args || {}).deep_symbolize_keys
case_defs = args.delete(:cases)
if case_defs.present?
case_defs.map do |case_args|
normalized_args = args.merge(case_args.symbolize_keys)
run_case(normalized_args, llm, execution_context:)
end
else
run_case(args, llm, execution_context:)
end
end
private
attr_reader :operation
def run_case(case_args, llm, execution_context:)
content = extract_content(case_args)
raise ArgumentError, "Translation evals require :input or :conversation" if content.blank?
output =
if operation == "locale_detector"
detect_locale(content, llm, execution_context:)
else
target_locale =
case_args[:target_locale].presence ||
raise(ArgumentError, "Translation evals require :target_locale")
translate_content(content, target_locale, llm, execution_context:)
end
build_payload(case_args, content, output)
end
def detect_locale(content, llm, execution_context:)
agent = agent_for_operation
context =
DiscourseAi::Agents::BotContext.new(
user: system_user,
skip_show_thinking: true,
feature_name: "translation/#{operation}",
messages: [{ type: :user, content: content }],
)
bot = DiscourseAi::Agents::Bot.as(system_user, agent: agent, model: llm)
capture_plain_response(bot, context, execution_context:).strip
end
def translate_content(content, target_locale, llm, execution_context:)
agent = agent_for_operation
payload = { content:, target_locale: }.to_json
context =
DiscourseAi::Agents::BotContext.new(
user: system_user,
skip_show_thinking: true,
feature_name: "translation/#{operation}",
messages: [{ type: :user, content: payload }],
)
bot = DiscourseAi::Agents::Bot.as(system_user, agent: agent, model: llm)
capture_structured_response(bot, context, schema_key: :output, execution_context:).strip
end
def build_payload(case_args, content, output)
metadata = {
message: content,
target_locale: case_args[:target_locale],
expected_locale: case_args[:expected_locale],
}.compact
wrap_result(output, metadata)
end
def extract_content(case_args)
if case_args[:conversation].present?
Array(case_args[:conversation]).map(&:to_s).join("\n\n")
else
(case_args[:input] || case_args[:message]).to_s
end
end
def system_user
@user ||= Discourse.system_user
end
def agent_for_operation
agent_class = OPERATIONS.fetch(operation)
resolve_agent(agent_class: agent_class)
end
end
end
end
end