0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-04 10:39:43 +08:00
discourse/plugins/discourse-ai/lib/workflow_schema_fields.rb
Joffrey JAFFEUX 4e5aa86db7
FEATURE: schema based workflow (#41684)
Previously, workflow fields were only known after execution or from
pinned data, limiting editor previews and AI-generated expressions. This
change adds versioned, configuration-aware output contracts using JSON
Schema.

Mostly based on what AI was doing with schemas, but we migrated to JSON
schema and adds helpers functions for AI to convert it to the format
used by AI.
2026-07-15 13:22:29 +02:00

165 lines
4.7 KiB
Ruby
Vendored

# frozen_string_literal: true
module DiscourseAi
module WorkflowSchemaFields
class << self
def convert(schema)
schema = effective(normalize(schema))
return {} unless object_schema?(schema)
flatten_properties(schema["properties"], nil, {})
end
private
def flatten_properties(properties, parent_path, result)
properties.each do |name, property_schema|
flatten_property(
effective(normalize(property_schema)),
property_path(parent_path, name),
result,
)
end
result
end
def flatten_property(schema, path, result)
if schema.key?("const")
result[path] = JSON.generate(schema["const"])
elsif object_schema?(schema)
result[path] = schema["description"].presence || compact_type(schema)
flatten_properties(schema["properties"], path, result)
elsif array_schema?(schema)
flatten_array(schema, path, result)
else
result[path] = compact_type(schema)
end
end
def flatten_array(schema, path, result)
item_schema = effective(normalize(schema["items"]))
if object_schema?(item_schema)
result[path] = schema["description"].presence || nullable_suffix("array<object>", schema)
flatten_properties(item_schema["properties"], "#{path}[0]", result)
else
result[path] = nullable_suffix("array<#{compact_type(item_schema)}>", schema)
end
end
def property_path(parent_path, name)
name = name.to_s
segment =
if name.match?(/\A[A-Za-z_$][A-Za-z0-9_$]*\z/)
parent_path.present? ? ".#{name}" : name
else
"[#{JSON.generate(name)}]"
end
"#{parent_path}#{segment}"
end
def effective(schema)
branches = schema["anyOf"]
return schema unless branches.is_a?(Array)
branches
.map { |branch| effective(normalize(branch)) }
.reduce(schema.except("anyOf")) { |combined, branch| display_union(combined, branch) }
end
def display_union(left, right)
return right if left.except("$schema").empty?
result = {}
types = type_list(left) | type_list(right)
result["type"] = types if types.any?
%w[const format description].each do |key|
result[key] = left[key] if left.key?(key) && left[key] == right[key]
end
if left["properties"].is_a?(Hash) || right["properties"].is_a?(Hash)
result["properties"] = (left["properties"] || {}).merge(
right["properties"] || {},
) do |_name, left_value, right_value|
display_union(effective(normalize(left_value)), effective(normalize(right_value)))
end
end
if left.key?("items") || right.key?("items")
result["items"] = display_union(
effective(normalize(left["items"])),
effective(normalize(right["items"])),
)
end
result
end
def type_list(schema)
types = Array(schema["type"]).map(&:to_s)
return types if types.any?
return [json_type(schema["const"])] if schema.key?("const")
return ["object"] if schema["properties"].is_a?(Hash)
return ["array"] if schema.key?("items")
[]
end
def json_type(value)
case value
when Array
"array"
when Hash
"object"
when Integer
"integer"
when Numeric
"number"
when TrueClass, FalseClass
"boolean"
when NilClass
"null"
else
"string"
end
end
def compact_type(schema)
types = type_list(schema)
return "null" if types == ["null"]
nullable = types.delete("null")
types = ["unknown"] if types.empty?
types.map! do |type|
type == "string" && schema["format"] == "date-time" ? "datetime" : type
end
types << "null" if nullable
types.join("|")
end
def nullable_suffix(type, schema)
type_list(schema).include?("null") ? "#{type}|null" : type
end
def object_schema?(schema)
sole_non_null_type?(schema, "object") && schema["properties"].is_a?(Hash)
end
def array_schema?(schema)
sole_non_null_type?(schema, "array") && schema["items"].is_a?(Hash)
end
def sole_non_null_type?(schema, type)
(type_list(schema) - ["null"]).uniq == [type]
end
def normalize(schema)
return {} unless schema.respond_to?(:to_h)
schema.to_h.deep_stringify_keys
end
end
end
end