0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-08 17:53:55 +08:00
discourse/plugins/discourse-ai/evals/lib/runners/inference.rb
Roman Rizzi 3a647c8e50
FEATURE: Use evals to compare LLMs and Personas' prompts (#36027)
Implemented an eval “comparison matrix” that lets you run the same evals
across multiple personas or multiple LLMs and have a judge model declare
a winner with per-candidate scores. The CLI adds --compare
personas|llms, keeps persona selection (auto-prepending default for
persona mode), and always ensures a judge is configured. A dedicated
ComparisonRunner reuses Workbench results to build candidate outputs and
sends them to Judge#compare, which crafts a rubric-aware comparison
prompt and parses structured winner/ratings JSON. Outputs are streamed
to the console and individual run logs still get written. README
documents how to use the new flag and what each mode does.
2025-11-18 10:39:52 -03:00

145 lines
4.5 KiB
Ruby
Vendored

# frozen_string_literal: true
require_relative "base"
module DiscourseAi
module Evals
module Runners
class Inference < Base
OPERATIONS = {
"generate_concepts" => {
persona_class: DiscourseAi::Personas::ConceptFinder,
schema_key: :concepts,
schema_type: "array",
},
"match_concepts" => {
persona_class: DiscourseAi::Personas::ConceptMatcher,
schema_key: :matching_concepts,
schema_type: "array",
},
"deduplicate_concepts" => {
persona_class: DiscourseAi::Personas::ConceptDeduplicator,
schema_key: :streamlined_tags,
schema_type: "array",
},
}.freeze
def self.can_handle?(feature_name)
feature_name&.start_with?("inference:")
end
def run(eval_case, llm)
args = eval_case.args || {}
response =
case feature_name
when "generate_concepts"
generate_concepts(args, llm)
when "match_concepts"
match_concepts(args, llm)
when "deduplicate_concepts"
deduplicate_concepts(args, llm)
else
raise ArgumentError, "Unsupported inference feature '#{feature_name}'"
end
response
end
private
def generate_concepts(args, llm)
content = conversation_to_text(args)
raise ArgumentError, "Missing input for generate concepts eval" if content.blank?
persona, user = persona_bundle(feature_name)
context =
build_ctx.tap do |ctx|
ctx.messages = [{ type: :user, content: content }]
ctx.inferred_concepts = args[:existing_concepts] || []
end
values = capture_structured_output(persona, user, llm, context, feature_name)
wrap_result(format_response(values), { query: content })
end
def match_concepts(args, llm)
content = conversation_to_text(args)
candidates = args[:concepts].to_a.map(&:to_s)
if content.blank? || candidates.empty?
raise ArgumentError, "Match concepts eval requires :input/:conversation and :concepts"
end
persona, user = persona_bundle(feature_name)
context =
build_ctx.tap do |ctx|
ctx.messages = [{ type: :user, content: content }]
ctx.inferred_concepts = candidates
end
values = capture_structured_output(persona, user, llm, context, feature_name)
wrap_result(format_response(values), { query: content, concepts: candidates })
end
def deduplicate_concepts(args, llm)
candidates = args[:concepts].to_a.map(&:to_s)
raise ArgumentError, "Deduplicate concepts eval requires :concepts" if candidates.empty?
persona, user = persona_bundle(feature_name)
context =
build_ctx.tap { |ctx| ctx.messages = [{ type: :user, content: candidates.join(", ") }] }
values = capture_structured_output(persona, user, llm, context, feature_name)
wrap_result(format_response(values), { concepts: candidates })
end
def persona_bundle(op)
config = OPERATIONS.fetch(op) { raise ArgumentError }
persona_klass = config.fetch(:persona_class)
resolve_persona(persona_class: persona_klass)
end
def capture_structured_output(persona, user, llm, context, op)
schema = OPERATIONS.fetch(op)
schema_key = schema[:schema_key]
schema_type = schema[:schema_type] || "array"
bot = DiscourseAi::Personas::Bot.as(user, persona: persona, model: llm)
capture_structured_response(
bot,
context,
schema_key: schema_key,
schema_type: schema_type,
)
end
def conversation_to_text(args)
if args[:conversation].present?
Array(args[:conversation]).join("\n\n")
else
args[:input].to_s
end
end
def format_response(values)
if values.is_a?(Array)
values.map { |item| item.to_s.strip }.reject(&:blank?).join("\n")
else
values.to_s
end
end
def build_ctx
DiscourseAi::Personas::BotContext.new(
user: Discourse.system_user,
skip_show_thinking: true,
feature_name: "evals/inference/#{feature_name}",
)
end
end
end
end
end