diff --git a/app/graphql/mutations/velorum/generate_flow.rb b/app/graphql/mutations/ai/generate_flow.rb similarity index 88% rename from app/graphql/mutations/velorum/generate_flow.rb rename to app/graphql/mutations/ai/generate_flow.rb index ba608c27..d4d9af59 100644 --- a/app/graphql/mutations/velorum/generate_flow.rb +++ b/app/graphql/mutations/ai/generate_flow.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true module Mutations - module Velorum + module Ai class GenerateFlow < BaseMutation - description 'Start a Velorum flow generation job.' + description 'Start an AI flow generation job.' field :execution_identifier, type: GraphQL::Types::String, @@ -17,7 +17,7 @@ class GenerateFlow < BaseMutation argument :model_identifier, type: GraphQL::Types::String, required: true, - description: 'Selected Velorum model identifier' + description: 'Selected AI model identifier' argument :project_id, type: Types::GlobalIdType[::NamespaceProject], required: true, @@ -25,10 +25,10 @@ class GenerateFlow < BaseMutation argument :prompt, type: GraphQL::Types::String, required: true, - description: 'Prompt to send to Velorum' + description: 'Prompt to send to AI' def resolve(project_id:, prompt:, model_identifier:, flow_id: nil) - return error_response(:invalid_setting, 'Velorum is disabled') unless velorum_enabled? + return error_response(:invalid_setting, 'AI is disabled') unless ai_enabled? project = SagittariusSchema.object_from_id(project_id) return error_response(:project_not_found, 'Invalid project id') if project.nil? @@ -50,7 +50,7 @@ def resolve(project_id:, prompt:, model_identifier:, flow_id: nil) private - def velorum_enabled? + def ai_enabled? Sagittarius::Configuration.config[:velorum][:enabled] end diff --git a/app/graphql/subscription_triggers.rb b/app/graphql/subscription_triggers.rb index 5cb369e1..37c6f4af 100644 --- a/app/graphql/subscription_triggers.rb +++ b/app/graphql/subscription_triggers.rb @@ -10,9 +10,9 @@ def self.execution_result(execution_result) ) end - def self.velorum_generate_flow(execution_identifier, flow) + def self.ai_generate_flow(execution_identifier, flow) SagittariusSchema.subscriptions.trigger( - :velorum_generate_flow, + :ai_generate_flow, { execution_identifier: execution_identifier }, flow, context: { visibility_profile: :execution } diff --git a/app/graphql/subscriptions/velorum/generate_flow.rb b/app/graphql/subscriptions/ai/generate_flow.rb similarity index 55% rename from app/graphql/subscriptions/velorum/generate_flow.rb rename to app/graphql/subscriptions/ai/generate_flow.rb index f502c236..4967dd59 100644 --- a/app/graphql/subscriptions/velorum/generate_flow.rb +++ b/app/graphql/subscriptions/ai/generate_flow.rb @@ -1,19 +1,19 @@ # frozen_string_literal: true module Subscriptions - module Velorum + module Ai class GenerateFlow < BaseSubscription - description 'Generate a flow through Velorum and close the subscription with the generated flow' + description 'Generate a flow through AI and close the subscription with the generated flow' argument :execution_identifier, type: GraphQL::Types::String, required: true, - description: 'Velorum generation request identifier returned by the mutation' + description: 'AI generation request identifier returned by the mutation' field :flow, - type: GraphQL::Types::JSON, + type: Types::Ai::GenerationFlowType, null: true, - description: 'Generated flow returned by Velorum' + description: 'Generated flow returned by AI' def subscribe(**) :no_response diff --git a/app/graphql/types/ai/generation_flow_setting_type.rb b/app/graphql/types/ai/generation_flow_setting_type.rb new file mode 100644 index 00000000..c425c45c --- /dev/null +++ b/app/graphql/types/ai/generation_flow_setting_type.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module Ai + class GenerationFlowSettingType < Types::BaseObject + description 'Represents a flow setting generated by AI.' + + field :cast, String, null: true, description: 'The generated cast applied to the flow setting.' + field :flow_setting_id, String, null: true, description: 'The generated flow setting identifier.' + field :id, Types::GlobalIdType[::FlowSetting], null: false, + description: 'Generated global ID for this setting.' + field :value, GraphQL::Types::JSON, null: true, description: 'The generated value of the flow setting.' + + def id + Sagittarius::Utils.generated_global_id(object[:id], ::FlowSetting) + end + end + end +end diff --git a/app/graphql/types/ai/generation_flow_type.rb b/app/graphql/types/ai/generation_flow_type.rb new file mode 100644 index 00000000..3dfd8a3b --- /dev/null +++ b/app/graphql/types/ai/generation_flow_type.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Types + module Ai + class GenerationFlowType < Types::BaseObject + description 'Represents a flow generated by AI.' + + field :name, String, null: true, description: 'Generated flow name.' + field :nodes, [Types::Ai::GenerationNodeFunctionType], + null: false, + description: 'Generated node functions of the flow.' + field :settings, [Types::Ai::GenerationFlowSettingType], + null: false, + description: 'Generated flow settings.' + field :starting_node_id, Types::GlobalIdType[::NodeFunction], + null: true, + description: 'Generated starting node ID.' + field :type, String, null: true, description: 'Generated flow type identifier.' + + def starting_node_id + Sagittarius::Utils.generated_global_id(object[:starting_node_id], ::NodeFunction) + end + end + end +end diff --git a/app/graphql/types/ai/generation_input_type_type.rb b/app/graphql/types/ai/generation_input_type_type.rb new file mode 100644 index 00000000..a6d36fc7 --- /dev/null +++ b/app/graphql/types/ai/generation_input_type_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + module Ai + class GenerationInputTypeType < Types::BaseObject + description 'Represents an input type reference generated by AI.' + + field :input_index, GraphQL::Types::Int, + null: true, + description: 'The generated referenced input index.' + field :node_id, Types::GlobalIdType[::NodeFunction], + null: true, + description: 'The generated referenced node ID.' + field :parameter_index, GraphQL::Types::Int, + null: true, + description: 'The generated referenced parameter index.' + + def node_id + Sagittarius::Utils.generated_global_id(object[:node_id], ::NodeFunction) + end + end + end +end diff --git a/app/graphql/types/ai/generation_node_function_type.rb b/app/graphql/types/ai/generation_node_function_type.rb new file mode 100644 index 00000000..d587b432 --- /dev/null +++ b/app/graphql/types/ai/generation_node_function_type.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Types + module Ai + class GenerationNodeFunctionType < Types::BaseObject + description 'Represents a node function generated by AI.' + + field :function_definition, Types::FunctionDefinitionType, + null: true, + description: 'Resolved function definition for the generated node.' + field :id, Types::GlobalIdType[::NodeFunction], null: false, description: 'Generated global ID for this node.' + field :next_node_id, Types::GlobalIdType[::NodeFunction], + null: true, + description: 'Generated next node ID.' + field :parameters, [Types::Ai::GenerationNodeParameterType], + null: false, + description: 'Generated node parameters.' + + def id + Sagittarius::Utils.generated_global_id(object[:id], ::NodeFunction) + end + + def next_node_id + Sagittarius::Utils.generated_global_id(object[:next_node_id], ::NodeFunction) + end + end + end +end diff --git a/app/graphql/types/ai/generation_node_parameter_type.rb b/app/graphql/types/ai/generation_node_parameter_type.rb new file mode 100644 index 00000000..8de9dc31 --- /dev/null +++ b/app/graphql/types/ai/generation_node_parameter_type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module Ai + class GenerationNodeParameterType < Types::BaseObject + description 'Represents a node parameter generated by AI.' + + field :cast, String, null: true, description: 'The generated cast applied to the parameter.' + field :id, Types::GlobalIdType[::NodeParameter], + null: false, + description: 'Generated global ID for this parameter.' + field :parameter_definition, Types::ParameterDefinitionType, + null: true, + description: 'Resolved parameter definition for the generated parameter.' + field :value, Types::Ai::GenerationNodeValueType, null: true, description: 'The generated parameter value.' + + def id + Sagittarius::Utils.generated_global_id(object[:id], ::NodeParameter) + end + end + end +end diff --git a/app/graphql/types/ai/generation_node_value_type.rb b/app/graphql/types/ai/generation_node_value_type.rb new file mode 100644 index 00000000..96c1c6fb --- /dev/null +++ b/app/graphql/types/ai/generation_node_value_type.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Types + module Ai + class GenerationNodeValueType < Types::BaseUnion + description 'Represents a node value generated by AI.' + + possible_types Types::LiteralValueType, + Types::Ai::GenerationReferenceValueType, + Types::Ai::GenerationSubFlowValueType + + def self.resolve_type(object, _context) + generated_value_type = object[:generated_value_type] || object['generated_value_type'] if object.is_a?(Hash) + + case generated_value_type&.to_sym + when :reference_value + Types::Ai::GenerationReferenceValueType + when :sub_flow_value + Types::Ai::GenerationSubFlowValueType + else + Types::LiteralValueType + end + end + end + end +end diff --git a/app/graphql/types/ai/generation_reference_path_type.rb b/app/graphql/types/ai/generation_reference_path_type.rb new file mode 100644 index 00000000..63840046 --- /dev/null +++ b/app/graphql/types/ai/generation_reference_path_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module Ai + class GenerationReferencePathType < Types::BaseObject + description 'Represents a reference path generated by AI.' + + field :array_index, GraphQL::Types::Int, + null: true, + description: 'The generated reference array index.' + field :path, String, + null: true, + description: 'The generated reference path.' + end + end +end diff --git a/app/graphql/types/ai/generation_reference_value_type.rb b/app/graphql/types/ai/generation_reference_value_type.rb new file mode 100644 index 00000000..f1eeaa6a --- /dev/null +++ b/app/graphql/types/ai/generation_reference_value_type.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Types + module Ai + class GenerationReferenceValueType < Types::BaseObject + description 'Represents a reference value generated by AI.' + + field :flow_input, Boolean, + null: true, + description: 'Whether this reference targets the flow input, matching the gRPC field.' + # rubocop:disable GraphQL/ExtractType -- keep both gRPC and existing frontend names available. + field :input_index, GraphQL::Types::Int, + null: true, + description: 'The generated referenced input index.' + field :input_type, Types::Ai::GenerationInputTypeType, + null: true, + description: 'The generated input type reference, matching the gRPC field.' + field :node_function_id, Types::GlobalIdType[::NodeFunction], + null: true, + description: 'The generated referenced node function ID.' + field :node_id, Types::GlobalIdType[::NodeFunction], + null: true, + description: 'The generated referenced node ID, matching the gRPC field.' + # rubocop:enable GraphQL/ExtractType + field :parameter_index, GraphQL::Types::Int, + null: true, + description: 'The generated referenced parameter index.' + field :reference_path, [Types::Ai::GenerationReferencePathType], + null: false, + description: 'The generated reference paths.' + + def node_function_id + Sagittarius::Utils.generated_global_id(object[:node_function_id], ::NodeFunction) + end + + def node_id + Sagittarius::Utils.generated_global_id(object[:node_id], ::NodeFunction) + end + end + end +end diff --git a/app/graphql/types/ai/generation_sub_flow_setting_type.rb b/app/graphql/types/ai/generation_sub_flow_setting_type.rb new file mode 100644 index 00000000..b095098e --- /dev/null +++ b/app/graphql/types/ai/generation_sub_flow_setting_type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module Ai + class GenerationSubFlowSettingType < Types::BaseObject + description 'Represents a sub-flow setting generated by AI.' + + field :default_value, GraphQL::Types::JSON, + null: true, + description: 'The generated default value of the sub-flow setting.' + field :hidden, Boolean, + null: true, + description: 'Whether the generated sub-flow setting is hidden.' + field :identifier, String, + null: true, + description: 'The generated sub-flow setting identifier.' + field :optional, Boolean, + null: true, + description: 'Whether the generated sub-flow setting is optional.' + end + end +end diff --git a/app/graphql/types/ai/generation_sub_flow_value_type.rb b/app/graphql/types/ai/generation_sub_flow_value_type.rb new file mode 100644 index 00000000..616d46e4 --- /dev/null +++ b/app/graphql/types/ai/generation_sub_flow_value_type.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Types + module Ai + class GenerationSubFlowValueType < Types::BaseObject + description 'Represents a sub-flow value generated by AI.' + + field :function_identifier, String, + null: true, + description: 'The generated sub-flow function identifier.' + field :settings, [Types::Ai::GenerationSubFlowSettingType], + null: false, + description: 'The generated sub-flow settings.' + field :signature, String, + null: true, + description: 'The generated sub-flow signature.' + field :starting_node_id, Types::GlobalIdType[::NodeFunction], + null: true, + description: 'The generated sub-flow starting node ID.' + + def starting_node_id + Sagittarius::Utils.generated_global_id(object[:starting_node_id], ::NodeFunction) + end + end + end +end diff --git a/app/graphql/types/velorum_model_type.rb b/app/graphql/types/ai_model_type.rb similarity index 72% rename from app/graphql/types/velorum_model_type.rb rename to app/graphql/types/ai_model_type.rb index 8e082db4..f79ea7d2 100644 --- a/app/graphql/types/velorum_model_type.rb +++ b/app/graphql/types/ai_model_type.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true module Types - class VelorumModelType < Types::BaseObject - description 'Represents a model available through Velorum' + class AiModelType < Types::BaseObject + description 'Represents a model available through AI' field :identifier, String, null: false, description: 'Unique model identifier' field :name, String, null: false, description: 'Human-readable model name' field :token_cost, Float, null: false, description: 'Token cost for using this model' - field :types, [Types::VelorumModelTypeEnum], + field :types, [Types::AiModelTypeEnum], null: false, description: 'Capabilities supported by this model', method: :type diff --git a/app/graphql/types/velorum_model_type_enum.rb b/app/graphql/types/ai_model_type_enum.rb similarity index 70% rename from app/graphql/types/velorum_model_type_enum.rb rename to app/graphql/types/ai_model_type_enum.rb index 17b8d519..b2be599d 100644 --- a/app/graphql/types/velorum_model_type_enum.rb +++ b/app/graphql/types/ai_model_type_enum.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true module Types - class VelorumModelTypeEnum < Types::BaseEnum - description 'Supported Velorum model capabilities' + class AiModelTypeEnum < Types::BaseEnum + description 'Supported AI model capabilities' value :UNKNOWN, 'Unknown model capability', value: :UNKNOWN value :EXPLAIN, 'Model can explain flows', value: :EXPLAIN diff --git a/app/graphql/types/velorum_type.rb b/app/graphql/types/ai_type.rb similarity index 64% rename from app/graphql/types/velorum_type.rb rename to app/graphql/types/ai_type.rb index 27ee6172..7e70c5b6 100644 --- a/app/graphql/types/velorum_type.rb +++ b/app/graphql/types/ai_type.rb @@ -1,14 +1,14 @@ # frozen_string_literal: true module Types - class VelorumType < Types::BaseObject - description 'Represents Velorum integration information' + class AiType < Types::BaseObject + description 'Represents AI integration information' authorize :read_velorum_config declarative_policy_subject { :global } - field :enabled, Boolean, null: false, description: 'Whether Velorum is enabled' - field :models, [Types::VelorumModelType], null: false, description: 'Find models available through Velorum' + field :enabled, Boolean, null: false, description: 'Whether AI is enabled' + field :models, [Types::AiModelType], null: false, description: 'Find models available through AI' def enabled config[:enabled] diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index fbead1bc..47964c83 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -7,6 +7,8 @@ class MutationType < Types::BaseObject include Sagittarius::Graphql::MountMutation mount_mutation Mutations::ApplicationSettings::Update + mount_mutation Mutations::Ai::GenerateFlow + mount_mutation Mutations::Echo mount_mutation Mutations::Namespaces::Members::AssignRoles mount_mutation Mutations::Namespaces::Members::Delete mount_mutation Mutations::Namespaces::Members::Invite @@ -47,8 +49,6 @@ class MutationType < Types::BaseObject mount_mutation Mutations::Users::PasswordReset mount_mutation Mutations::Users::Register mount_mutation Mutations::Users::Update - mount_mutation Mutations::Velorum::GenerateFlow - mount_mutation Mutations::Echo end end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 0ace025a..e1186c66 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -42,8 +42,8 @@ class QueryType < Types::BaseObject field :users, Types::UserType.connection_type, null: false, description: 'Find users' + field :ai, Types::AiType, null: true, description: 'Get AI information' field :global_runtimes, Types::RuntimeType.connection_type, null: false, description: 'Find runtimes' - field :velorum, Types::VelorumType, null: true, description: 'Get Velorum information' def application {} @@ -87,7 +87,7 @@ def global_runtimes Runtime.where(namespace: nil) end - def velorum + def ai {} end diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb index e28dd2e2..3b5b437c 100644 --- a/app/graphql/types/subscription_type.rb +++ b/app/graphql/types/subscription_type.rb @@ -6,8 +6,8 @@ class SubscriptionType < Types::BaseObject include Sagittarius::Graphql::MountSubscription - mount_subscription Subscriptions::Namespaces::Projects::Flows::ExecutionResult - mount_subscription Subscriptions::Velorum::GenerateFlow + mount_subscription Subscriptions::Ai::GenerateFlow mount_subscription Subscriptions::Echo + mount_subscription Subscriptions::Namespaces::Projects::Flows::ExecutionResult end end diff --git a/app/jobs/velorum_generate_flow_job.rb b/app/jobs/velorum_generate_flow_job.rb index 52bc245c..1cfdc60f 100644 --- a/app/jobs/velorum_generate_flow_job.rb +++ b/app/jobs/velorum_generate_flow_job.rb @@ -16,8 +16,7 @@ def perform(execution_identifier, project_id, prompt, model_identifier, flow_id flow: flow, authorize: false ).execute - return unless response.success? - SubscriptionTriggers.velorum_generate_flow(execution_identifier, response.payload[:flow]) + SubscriptionTriggers.ai_generate_flow(execution_identifier, response.success? ? response.payload[:flow] : nil) end end diff --git a/app/services/error_code.rb b/app/services/error_code.rb index 222d6da7..a91f2b7e 100644 --- a/app/services/error_code.rb +++ b/app/services/error_code.rb @@ -58,6 +58,7 @@ def self.error_codes invalid_namespace_member: { description: 'The namespace member is invalid because of active model errors' }, invalid_attachment: { description: 'The attachment is invalid because of active model errors' }, invalid_flow: { description: 'The flow is invalid because of active model errors' }, + flow_generation_failed: { description: 'Flow generation failed' }, project_not_found: { description: 'The namespace project with the given identifier was not found' }, runtime_not_found: { description: 'The runtime with the given identifier was not found' }, runtime_not_assigned: { description: 'The runtime is not assigned to the project' }, diff --git a/app/services/velorum/generate_flow_service.rb b/app/services/velorum/generate_flow_service.rb index b52b37d6..ed3375bb 100644 --- a/app/services/velorum/generate_flow_service.rb +++ b/app/services/velorum/generate_flow_service.rb @@ -39,11 +39,13 @@ def execute ServiceResponse.success( message: 'Generated flow', payload: { - flow: GenerationFlowSerializer.new(response.flow).to_h, + flow: GenerationFlowSerializer.new(response.flow, project: project).to_h, cached_until: response.cached_until, usage: response.usage, } ) + rescue GRPC::BadStatus => e + flow_generation_failed_response(e) end private @@ -136,5 +138,16 @@ def missing_permission_response def no_primary_runtime_response ServiceResponse.error(message: 'Project has no primary runtime', error_code: :no_primary_runtime) end + + def flow_generation_failed_response(error) + ServiceResponse.error( + message: 'Flow generation failed', + error_code: :flow_generation_failed, + details: { + grpc_code: error.respond_to?(:code) ? error.code : nil, + grpc_details: error.respond_to?(:details) ? error.details : error.message, + } + ) + end end end diff --git a/app/services/velorum/generation_flow_serializer.rb b/app/services/velorum/generation_flow_serializer.rb index a249b44a..ee4ee2f2 100644 --- a/app/services/velorum/generation_flow_serializer.rb +++ b/app/services/velorum/generation_flow_serializer.rb @@ -2,75 +2,140 @@ module Velorum class GenerationFlowSerializer - def initialize(flow) + def initialize(flow, project: nil) @flow = flow + @project = project + @node_id_by_source_id = {} + @generated_node_ids = {}.compare_by_identity + @function_definitions_by_runtime_id = {} + @parameter_definitions_by_node = {}.compare_by_identity end def to_h + prepare_node_ids + prepare_definition_ids + { name: flow.name, type: flow.type, - starting_node_id: blank_zero(flow.starting_node_id), - settings: flow.settings.map { |setting| flow_setting_to_h(setting) }, - nodes: flow.node_functions.map { |node| node_to_h(node) }, + starting_node_id: node_reference_id(flow.starting_node_id) || generated_starting_node_id, + settings: flow.settings.map.with_index { |setting, index| flow_setting_to_h(setting, index) }, + nodes: serialized_nodes, } end private - attr_reader :flow + attr_reader :flow, :project, :node_id_by_source_id, :generated_node_ids, :function_definitions_by_runtime_id, + :parameter_definitions_by_node + + def serialized_nodes + @serialized_nodes ||= flow.node_functions.map.with_index { |node, index| node_to_h(node, index) } + end + + def prepare_node_ids + flow.node_functions.each_with_index do |node, index| + source_id = blank_zero(node.database_id) + node_id = source_id&.to_s || "generated-#{index + 1}" + + node_id_by_source_id[source_id.to_s] = node_id if source_id.present? + generated_node_ids[node] = node_id + end + end + + def prepare_definition_ids + return if runtime.nil? + + definitions = runtime.function_definitions + if definitions.respond_to?(:includes) + definitions = definitions.includes(:runtime_function_definition, :parameter_definitions) + end + definitions = definitions.respond_to?(:find_each) ? definitions.find_each : definitions.each + + definitions.each do |definition| + next unless definition.respond_to?(:identifier) + + function_definitions_by_runtime_id[definition.identifier] ||= definition + runtime_name = if definition.respond_to?(:runtime_function_definition) + definition.runtime_function_definition&.runtime_name + end + function_definitions_by_runtime_id[runtime_name] ||= definition if runtime_name.present? + end + end + + def node_to_h(node, index) + function_definition = function_definition_for(node) - def node_to_h(node) { - id: blank_zero(node.database_id), - function_identifier: node.runtime_function_id, - next_node_id: blank_zero(node.next_node_id), - definition_source: node.definition_source, - parameters: node.parameters.map { |parameter| parameter_to_h(parameter) }, + id: generated_node_ids.fetch(node), + function_definition: function_definition, + next_node_id: node_reference_id(node.next_node_id) || generated_next_node_id(index), + parameters: node.parameters.map.with_index do |parameter, parameter_index| + parameter_to_h(parameter, index, parameter_index, function_definition) + end, } end - def parameter_to_h(parameter) + def parameter_to_h(parameter, node_index, parameter_index, function_definition) + parameter_definition = parameter_definition_for(function_definition, parameter_index) + parameter_id = blank_zero(parameter.database_id) || "generated-parameter-#{node_index + 1}-#{parameter_index + 1}" + { - id: blank_zero(parameter.database_id), - parameter_identifier: parameter.runtime_parameter_id, + id: parameter_id, + parameter_definition: parameter_definition, cast: parameter.cast, - value: node_value_to_h(parameter.value), + value: node_value_to_h(parameter.value, parameter_id), } end - def node_value_to_h(value) - return {} if value.nil? + def node_value_to_h(value, id) + return if value.nil? if value.literal_value - { literal_value: value.literal_value.to_ruby(true) } + value.literal_value.to_ruby(true) elsif value.reference_value - { reference_value: reference_value_to_h(value.reference_value) } + reference_value_to_h(value.reference_value, id) elsif value.sub_flow - { sub_flow_value: sub_flow_to_h(value.sub_flow) } - else - {} + sub_flow_to_h(value.sub_flow) end end - def reference_value_to_h(value) - hash = { - reference_path: value.paths.map { |path| reference_path_to_h(path) }, - } + def reference_value_to_h(value, id) + node_function_id = nil + parameter_index = nil + input_index = nil if value.input_type - hash[:node_function_id] = blank_zero(value.input_type.node_id) - hash[:parameter_index] = blank_zero(value.input_type.parameter_index) - hash[:input_index] = blank_zero(value.input_type.input_index) + input_type = input_type_to_h(value.input_type) + node_function_id = input_type[:node_id] + parameter_index = input_type[:parameter_index] + input_index = input_type[:input_index] elsif !value.flow_input - hash[:node_function_id] = blank_zero(value.node_id) + node_function_id = node_reference_id(value.node_id) end - hash + { + generated_value_type: :reference_value, + id: "#{id}-reference", + node_function_id: node_function_id, + parameter_index: parameter_index, + input_index: input_index, + input_type_identifier: nil, + reference_path: value.paths.map.with_index { |path, index| reference_path_to_h(path, id, index) }, + } end - def reference_path_to_h(path) + def input_type_to_h(input_type) { + node_id: node_reference_id(input_type.node_id), + parameter_index: blank_zero(input_type.parameter_index), + input_index: blank_zero(input_type.input_index), + } + end + + def reference_path_to_h(path, value_id, index) + { + id: "#{value_id}-reference-path-#{index + 1}", path: path.path, array_index: blank_zero(path.array_index), } @@ -78,7 +143,8 @@ def reference_path_to_h(path) def sub_flow_to_h(sub_flow) { - starting_node_id: blank_zero(sub_flow.starting_node_id), + generated_value_type: :sub_flow_value, + starting_node_id: node_reference_id(sub_flow.starting_node_id), function_identifier: sub_flow.function_identifier, signature: sub_flow.signature, settings: sub_flow.settings.map { |setting| sub_flow_setting_to_h(setting) }, @@ -94,19 +160,55 @@ def sub_flow_setting_to_h(setting) } end - def flow_setting_to_h(setting) + def flow_setting_to_h(setting, index) { - id: blank_zero(setting.database_id), + id: blank_zero(setting.database_id) || "generated-setting-#{index + 1}", flow_setting_id: setting.flow_setting_id, value: setting.value&.to_ruby(true), cast: setting.cast, } end + def node_reference_id(value) + value = blank_zero(value) + return if value.blank? + + node_id_by_source_id.fetch(value.to_s, value) + end + + def generated_starting_node_id + generated_node_ids.values.first + end + + def generated_next_node_id(index) + flow.node_functions[index + 1]&.then { |next_node| generated_node_ids.fetch(next_node) } + end + def blank_zero(value) return if value.blank? || value.to_s == '0' value end + + def function_definition_for(node) + function_definitions_by_runtime_id[node.runtime_function_id] + end + + def parameter_definition_for(function_definition, index) + return if function_definition.nil? + + parameter_definitions_by_node[function_definition] ||= ordered_parameter_definitions(function_definition) + parameter_definitions_by_node[function_definition][index] + end + + def ordered_parameter_definitions(function_definition) + function_definition + .parameter_definitions + .sort_by { |definition| definition.runtime_parameter_definition&.id || definition.id } + end + + def runtime + project&.primary_runtime + end end end diff --git a/docs/graphql/enum/velorummodeltype.md b/docs/graphql/enum/aimodeltype.md similarity index 74% rename from docs/graphql/enum/velorummodeltype.md rename to docs/graphql/enum/aimodeltype.md index 278b2d20..afb09018 100644 --- a/docs/graphql/enum/velorummodeltype.md +++ b/docs/graphql/enum/aimodeltype.md @@ -1,8 +1,8 @@ --- -title: VelorumModelType +title: AiModelType --- -Supported Velorum model capabilities +Supported AI model capabilities | Value | Description | |-------|-------------| diff --git a/docs/graphql/enum/errorcodeenum.md b/docs/graphql/enum/errorcodeenum.md index 2da97bc9..28d76737 100644 --- a/docs/graphql/enum/errorcodeenum.md +++ b/docs/graphql/enum/errorcodeenum.md @@ -18,6 +18,7 @@ Represents the available error responses | `FAILED_TO_INVALIDATE_OLD_BACKUP_CODES` | The old backup codes could not be deleted | | `FAILED_TO_RESET_PASSWORD` | Failed to reset the user password | | `FAILED_TO_SAVE_VALID_BACKUP_CODE` | The new backup codes could not be saved | +| `FLOW_GENERATION_FAILED` | Flow generation failed | | `FLOW_NOT_FOUND` | The flow with the given identifier was not found | | `FLOW_TYPE_NOT_FOUND` | The flow type with the given identifier was not found | | `GENERIC_KEY_NOT_FOUND` | The given key was not found in the data type | diff --git a/docs/graphql/mutation/velorumgenerateflow.md b/docs/graphql/mutation/aigenerateflow.md similarity index 90% rename from docs/graphql/mutation/velorumgenerateflow.md rename to docs/graphql/mutation/aigenerateflow.md index ff8bd5a9..8f78e8d7 100644 --- a/docs/graphql/mutation/velorumgenerateflow.md +++ b/docs/graphql/mutation/aigenerateflow.md @@ -1,8 +1,8 @@ --- -title: velorumGenerateFlow +title: aiGenerateFlow --- -Start a Velorum flow generation job. +Start an AI flow generation job. ## Arguments @@ -10,9 +10,9 @@ Start a Velorum flow generation job. |------|------|-------------| | `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | | `flowId` | [`FlowID`](../scalar/flowid.md) | Flow to update with the prompt | -| `modelIdentifier` | [`String!`](../scalar/string.md) | Selected Velorum model identifier | +| `modelIdentifier` | [`String!`](../scalar/string.md) | Selected AI model identifier | | `projectId` | [`NamespaceProjectID!`](../scalar/namespaceprojectid.md) | Project to generate a flow for | -| `prompt` | [`String!`](../scalar/string.md) | Prompt to send to Velorum | +| `prompt` | [`String!`](../scalar/string.md) | Prompt to send to AI | ## Fields diff --git a/docs/graphql/object/ai.md b/docs/graphql/object/ai.md new file mode 100644 index 00000000..9c055239 --- /dev/null +++ b/docs/graphql/object/ai.md @@ -0,0 +1,12 @@ +--- +title: Ai +--- + +Represents AI integration information + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `enabled` | [`Boolean!`](../scalar/boolean.md) | Whether AI is enabled | +| `models` | [`[AiModel!]!`](../object/aimodel.md) | Find models available through AI | diff --git a/docs/graphql/object/aigenerationflow.md b/docs/graphql/object/aigenerationflow.md new file mode 100644 index 00000000..eda8d492 --- /dev/null +++ b/docs/graphql/object/aigenerationflow.md @@ -0,0 +1,15 @@ +--- +title: AiGenerationFlow +--- + +Represents a flow generated by AI. + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `name` | [`String`](../scalar/string.md) | Generated flow name. | +| `nodes` | [`[AiGenerationNodeFunction!]!`](../object/aigenerationnodefunction.md) | Generated node functions of the flow. | +| `settings` | [`[AiGenerationFlowSetting!]!`](../object/aigenerationflowsetting.md) | Generated flow settings. | +| `startingNodeId` | [`NodeFunctionID`](../scalar/nodefunctionid.md) | Generated starting node ID. | +| `type` | [`String`](../scalar/string.md) | Generated flow type identifier. | diff --git a/docs/graphql/object/aigenerationflowsetting.md b/docs/graphql/object/aigenerationflowsetting.md new file mode 100644 index 00000000..1cf519cc --- /dev/null +++ b/docs/graphql/object/aigenerationflowsetting.md @@ -0,0 +1,14 @@ +--- +title: AiGenerationFlowSetting +--- + +Represents a flow setting generated by AI. + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `cast` | [`String`](../scalar/string.md) | The generated cast applied to the flow setting. | +| `flowSettingId` | [`String`](../scalar/string.md) | The generated flow setting identifier. | +| `id` | [`FlowSettingID!`](../scalar/flowsettingid.md) | Generated global ID for this setting. | +| `value` | [`JSON`](../scalar/json.md) | The generated value of the flow setting. | diff --git a/docs/graphql/object/aigenerationinputtype.md b/docs/graphql/object/aigenerationinputtype.md new file mode 100644 index 00000000..190f1027 --- /dev/null +++ b/docs/graphql/object/aigenerationinputtype.md @@ -0,0 +1,13 @@ +--- +title: AiGenerationInputType +--- + +Represents an input type reference generated by AI. + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `inputIndex` | [`Int`](../scalar/int.md) | The generated referenced input index. | +| `nodeId` | [`NodeFunctionID`](../scalar/nodefunctionid.md) | The generated referenced node ID. | +| `parameterIndex` | [`Int`](../scalar/int.md) | The generated referenced parameter index. | diff --git a/docs/graphql/object/aigenerationnodefunction.md b/docs/graphql/object/aigenerationnodefunction.md new file mode 100644 index 00000000..c64e5950 --- /dev/null +++ b/docs/graphql/object/aigenerationnodefunction.md @@ -0,0 +1,14 @@ +--- +title: AiGenerationNodeFunction +--- + +Represents a node function generated by AI. + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `functionDefinition` | [`FunctionDefinition`](../object/functiondefinition.md) | Resolved function definition for the generated node. | +| `id` | [`NodeFunctionID!`](../scalar/nodefunctionid.md) | Generated global ID for this node. | +| `nextNodeId` | [`NodeFunctionID`](../scalar/nodefunctionid.md) | Generated next node ID. | +| `parameters` | [`[AiGenerationNodeParameter!]!`](../object/aigenerationnodeparameter.md) | Generated node parameters. | diff --git a/docs/graphql/object/aigenerationnodeparameter.md b/docs/graphql/object/aigenerationnodeparameter.md new file mode 100644 index 00000000..951a5d8a --- /dev/null +++ b/docs/graphql/object/aigenerationnodeparameter.md @@ -0,0 +1,14 @@ +--- +title: AiGenerationNodeParameter +--- + +Represents a node parameter generated by AI. + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `cast` | [`String`](../scalar/string.md) | The generated cast applied to the parameter. | +| `id` | [`NodeParameterID!`](../scalar/nodeparameterid.md) | Generated global ID for this parameter. | +| `parameterDefinition` | [`ParameterDefinition`](../object/parameterdefinition.md) | Resolved parameter definition for the generated parameter. | +| `value` | [`AiGenerationNodeValue`](../union/aigenerationnodevalue.md) | The generated parameter value. | diff --git a/docs/graphql/object/aigenerationreferencepath.md b/docs/graphql/object/aigenerationreferencepath.md new file mode 100644 index 00000000..cfda1ec7 --- /dev/null +++ b/docs/graphql/object/aigenerationreferencepath.md @@ -0,0 +1,12 @@ +--- +title: AiGenerationReferencePath +--- + +Represents a reference path generated by AI. + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `arrayIndex` | [`Int`](../scalar/int.md) | The generated reference array index. | +| `path` | [`String`](../scalar/string.md) | The generated reference path. | diff --git a/docs/graphql/object/aigenerationreferencevalue.md b/docs/graphql/object/aigenerationreferencevalue.md new file mode 100644 index 00000000..f0c9fc8b --- /dev/null +++ b/docs/graphql/object/aigenerationreferencevalue.md @@ -0,0 +1,17 @@ +--- +title: AiGenerationReferenceValue +--- + +Represents a reference value generated by AI. + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `flowInput` | [`Boolean`](../scalar/boolean.md) | Whether this reference targets the flow input, matching the gRPC field. | +| `inputIndex` | [`Int`](../scalar/int.md) | The generated referenced input index. | +| `inputType` | [`AiGenerationInputType`](../object/aigenerationinputtype.md) | The generated input type reference, matching the gRPC field. | +| `nodeFunctionId` | [`NodeFunctionID`](../scalar/nodefunctionid.md) | The generated referenced node function ID. | +| `nodeId` | [`NodeFunctionID`](../scalar/nodefunctionid.md) | The generated referenced node ID, matching the gRPC field. | +| `parameterIndex` | [`Int`](../scalar/int.md) | The generated referenced parameter index. | +| `referencePath` | [`[AiGenerationReferencePath!]!`](../object/aigenerationreferencepath.md) | The generated reference paths. | diff --git a/docs/graphql/object/aigenerationsubflowsetting.md b/docs/graphql/object/aigenerationsubflowsetting.md new file mode 100644 index 00000000..4a0bdb11 --- /dev/null +++ b/docs/graphql/object/aigenerationsubflowsetting.md @@ -0,0 +1,14 @@ +--- +title: AiGenerationSubFlowSetting +--- + +Represents a sub-flow setting generated by AI. + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `defaultValue` | [`JSON`](../scalar/json.md) | The generated default value of the sub-flow setting. | +| `hidden` | [`Boolean`](../scalar/boolean.md) | Whether the generated sub-flow setting is hidden. | +| `identifier` | [`String`](../scalar/string.md) | The generated sub-flow setting identifier. | +| `optional` | [`Boolean`](../scalar/boolean.md) | Whether the generated sub-flow setting is optional. | diff --git a/docs/graphql/object/aigenerationsubflowvalue.md b/docs/graphql/object/aigenerationsubflowvalue.md new file mode 100644 index 00000000..f42fa57f --- /dev/null +++ b/docs/graphql/object/aigenerationsubflowvalue.md @@ -0,0 +1,14 @@ +--- +title: AiGenerationSubFlowValue +--- + +Represents a sub-flow value generated by AI. + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `functionIdentifier` | [`String`](../scalar/string.md) | The generated sub-flow function identifier. | +| `settings` | [`[AiGenerationSubFlowSetting!]!`](../object/aigenerationsubflowsetting.md) | The generated sub-flow settings. | +| `signature` | [`String`](../scalar/string.md) | The generated sub-flow signature. | +| `startingNodeId` | [`NodeFunctionID`](../scalar/nodefunctionid.md) | The generated sub-flow starting node ID. | diff --git a/docs/graphql/object/velorummodel.md b/docs/graphql/object/aimodel.md similarity index 66% rename from docs/graphql/object/velorummodel.md rename to docs/graphql/object/aimodel.md index 6fd88c97..2ec1b4f2 100644 --- a/docs/graphql/object/velorummodel.md +++ b/docs/graphql/object/aimodel.md @@ -1,8 +1,8 @@ --- -title: VelorumModel +title: AiModel --- -Represents a model available through Velorum +Represents a model available through AI ## Fields without arguments @@ -11,4 +11,4 @@ Represents a model available through Velorum | `identifier` | [`String!`](../scalar/string.md) | Unique model identifier | | `name` | [`String!`](../scalar/string.md) | Human-readable model name | | `tokenCost` | [`Float!`](../scalar/float.md) | Token cost for using this model | -| `types` | [`[VelorumModelType!]!`](../enum/velorummodeltype.md) | Capabilities supported by this model | +| `types` | [`[AiModelType!]!`](../enum/aimodeltype.md) | Capabilities supported by this model | diff --git a/docs/graphql/object/query.md b/docs/graphql/object/query.md index d9fa2e8f..a6e33784 100644 --- a/docs/graphql/object/query.md +++ b/docs/graphql/object/query.md @@ -8,13 +8,13 @@ Root Query type | Name | Type | Description | |------|------|-------------| +| `ai` | [`Ai`](../object/ai.md) | Get AI information | | `application` | [`Application!`](../object/application.md) | Get application information | | `currentAuthentication` | [`Authentication`](../union/authentication.md) | Get the currently logged in authentication | | `currentUser` | [`User`](../object/user.md) | Get the currently logged in user | | `globalRuntimes` | [`RuntimeConnection!`](../object/runtimeconnection.md) | Find runtimes | | `organizations` | [`OrganizationConnection!`](../object/organizationconnection.md) | Find organizations | | `users` | [`UserConnection!`](../object/userconnection.md) | Find users | -| `velorum` | [`Velorum`](../object/velorum.md) | Get Velorum information | ## Fields with arguments diff --git a/docs/graphql/object/velorum.md b/docs/graphql/object/velorum.md deleted file mode 100644 index 31ecdbd7..00000000 --- a/docs/graphql/object/velorum.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Velorum ---- - -Represents Velorum integration information - -## Fields without arguments - -| Name | Type | Description | -|------|------|-------------| -| `enabled` | [`Boolean!`](../scalar/boolean.md) | Whether Velorum is enabled | -| `models` | [`[VelorumModel!]!`](../object/velorummodel.md) | Find models available through Velorum | diff --git a/docs/graphql/subscription/aigenerateflow.md b/docs/graphql/subscription/aigenerateflow.md new file mode 100644 index 00000000..0019897f --- /dev/null +++ b/docs/graphql/subscription/aigenerateflow.md @@ -0,0 +1,17 @@ +--- +title: aiGenerateFlow +--- + +Generate a flow through AI and close the subscription with the generated flow + +## Arguments + +| Name | Type | Description | +|------|------|-------------| +| `executionIdentifier` | [`String!`](../scalar/string.md) | AI generation request identifier returned by the mutation | + +## Fields + +| Name | Type | Description | +|------|------|-------------| +| `flow` | [`AiGenerationFlow`](../object/aigenerationflow.md) | Generated flow returned by AI | diff --git a/docs/graphql/subscription/velorumgenerateflow.md b/docs/graphql/subscription/velorumgenerateflow.md deleted file mode 100644 index dfe25b78..00000000 --- a/docs/graphql/subscription/velorumgenerateflow.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: velorumGenerateFlow ---- - -Generate a flow through Velorum and close the subscription with the generated flow - -## Arguments - -| Name | Type | Description | -|------|------|-------------| -| `executionIdentifier` | [`String!`](../scalar/string.md) | Velorum generation request identifier returned by the mutation | - -## Fields - -| Name | Type | Description | -|------|------|-------------| -| `flow` | [`JSON`](../scalar/json.md) | Generated flow returned by Velorum | diff --git a/docs/graphql/union/aigenerationnodevalue.md b/docs/graphql/union/aigenerationnodevalue.md new file mode 100644 index 00000000..f4a0f45e --- /dev/null +++ b/docs/graphql/union/aigenerationnodevalue.md @@ -0,0 +1,11 @@ +--- +title: AiGenerationNodeValue +--- + +Represents a node value generated by AI. + +## Possible types + +- [`AiGenerationReferenceValue`](../object/aigenerationreferencevalue.md) +- [`AiGenerationSubFlowValue`](../object/aigenerationsubflowvalue.md) +- [`LiteralValue`](../object/literalvalue.md) diff --git a/lib/sagittarius/utils.rb b/lib/sagittarius/utils.rb index 7d1bd346..4dd41e92 100644 --- a/lib/sagittarius/utils.rb +++ b/lib/sagittarius/utils.rb @@ -18,5 +18,18 @@ def to_boolean(value, default: nil) def monotonic_time Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second) end + + def generated_global_id(value, model_class) + return if value.blank? + return value if value.respond_to?(:model_class) + + GlobalID.new( + URI::GID.build( + app: GlobalID.app, + model_name: model_class.name, + model_id: value.to_s + ) + ) + end end end diff --git a/spec/graphql/types/velorum_model_type_spec.rb b/spec/graphql/types/ai_model_type_spec.rb similarity index 66% rename from spec/graphql/types/velorum_model_type_spec.rb rename to spec/graphql/types/ai_model_type_spec.rb index f3d21be1..78f72dae 100644 --- a/spec/graphql/types/velorum_model_type_spec.rb +++ b/spec/graphql/types/ai_model_type_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe Types::VelorumModelType do +RSpec.describe Types::AiModelType do let(:fields) do %w[ identifier @@ -12,6 +12,6 @@ ] end - it { expect(described_class.graphql_name).to eq('VelorumModel') } + it { expect(described_class.graphql_name).to eq('AiModel') } it { expect(described_class).to have_graphql_fields(fields) } end diff --git a/spec/graphql/types/velorum_type_spec.rb b/spec/graphql/types/ai_type_spec.rb similarity index 73% rename from spec/graphql/types/velorum_type_spec.rb rename to spec/graphql/types/ai_type_spec.rb index b7be2a12..4c801edb 100644 --- a/spec/graphql/types/velorum_type_spec.rb +++ b/spec/graphql/types/ai_type_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe Types::VelorumType do +RSpec.describe Types::AiType do let(:fields) do %w[ enabled @@ -10,7 +10,7 @@ ] end - it { expect(described_class.graphql_name).to eq('Velorum') } + it { expect(described_class.graphql_name).to eq('Ai') } it { expect(described_class).to have_graphql_fields(fields) } it { expect(described_class).to require_graphql_authorizations(:read_velorum_config) } end diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb index f2ef2f3c..176e1ff3 100644 --- a/spec/graphql/types/query_type_spec.rb +++ b/spec/graphql/types/query_type_spec.rb @@ -14,7 +14,7 @@ users user global_runtimes - velorum + ai namespace ] end diff --git a/spec/jobs/velorum_generate_flow_job_spec.rb b/spec/jobs/velorum_generate_flow_job_spec.rb index fc3ab5f7..b2663b17 100644 --- a/spec/jobs/velorum_generate_flow_job_spec.rb +++ b/spec/jobs/velorum_generate_flow_job_spec.rb @@ -13,7 +13,7 @@ before do allow(Velorum::GenerateFlowService).to receive(:new).and_return(service) - allow(SubscriptionTriggers).to receive(:velorum_generate_flow) + allow(SubscriptionTriggers).to receive(:ai_generate_flow) end it 'calls Velorum in the background and triggers the subscription response' do @@ -29,7 +29,7 @@ flow: nil, authorize: false ) - expect(SubscriptionTriggers).to have_received(:velorum_generate_flow).with(execution_identifier, flow) + expect(SubscriptionTriggers).to have_received(:ai_generate_flow).with(execution_identifier, flow) end it 'does not trigger the subscription when the project is gone' do @@ -38,6 +38,20 @@ end expect(Velorum::GenerateFlowService).not_to have_received(:new) - expect(SubscriptionTriggers).not_to have_received(:velorum_generate_flow) + expect(SubscriptionTriggers).not_to have_received(:ai_generate_flow) + end + + context 'when flow generation fails' do + let(:service_response) do + ServiceResponse.error(message: 'Flow generation failed', error_code: :flow_generation_failed) + end + + it 'triggers a nil subscription response to close the stream' do + perform_enqueued_jobs do + described_class.perform_later(execution_identifier, project.id, 'Generate a flow', 'gpt-5') + end + + expect(SubscriptionTriggers).to have_received(:ai_generate_flow).with(execution_identifier, nil) + end end end diff --git a/spec/requests/graphql/mutation/velorum/generate_flow_spec.rb b/spec/requests/graphql/mutation/ai/generate_flow_spec.rb similarity index 74% rename from spec/requests/graphql/mutation/velorum/generate_flow_spec.rb rename to spec/requests/graphql/mutation/ai/generate_flow_spec.rb index 474dc02b..d070484d 100644 --- a/spec/requests/graphql/mutation/velorum/generate_flow_spec.rb +++ b/spec/requests/graphql/mutation/ai/generate_flow_spec.rb @@ -2,15 +2,15 @@ require 'rails_helper' -RSpec.describe 'velorumGenerateFlow Mutation' do +RSpec.describe 'aiGenerateFlow Mutation' do include GraphqlHelpers subject(:mutate!) { post_graphql mutation, variables: variables, current_user: current_user } let(:mutation) do <<~GQL - mutation($input: VelorumGenerateFlowInput!) { - velorumGenerateFlow(input: $input) { + mutation($input: AiGenerateFlowInput!) { + aiGenerateFlow(input: $input) { executionIdentifier #{error_query} } @@ -43,9 +43,9 @@ it 'returns an execution identifier and enqueues the Velorum generation job' do mutate! - execution_identifier = graphql_data_at(:velorum_generate_flow, :execution_identifier) + execution_identifier = graphql_data_at(:ai_generate_flow, :execution_identifier) expect(execution_identifier).to be_present - expect(graphql_data_at(:velorum_generate_flow, :errors)).to eq([]) + expect(graphql_data_at(:ai_generate_flow, :errors)).to eq([]) expect(VelorumGenerateFlowJob).to have_received(:perform_later).with( execution_identifier, project.id, @@ -55,7 +55,7 @@ ) end - context 'when Velorum is disabled' do + context 'when AI is disabled' do before do allow(Sagittarius::Configuration).to receive(:config) .and_return(velorum: { enabled: false }) @@ -64,8 +64,8 @@ it 'returns an error and does not enqueue a job' do mutate! - expect(graphql_data_at(:velorum_generate_flow, :execution_identifier)).to be_nil - expect(graphql_data_at(:velorum_generate_flow, :errors, 0, :error_code)).to eq('INVALID_SETTING') + expect(graphql_data_at(:ai_generate_flow, :execution_identifier)).to be_nil + expect(graphql_data_at(:ai_generate_flow, :errors, 0, :error_code)).to eq('INVALID_SETTING') expect(VelorumGenerateFlowJob).not_to have_received(:perform_later) end end diff --git a/spec/requests/graphql/query/velorum_query_spec.rb b/spec/requests/graphql/query/ai_query_spec.rb similarity index 82% rename from spec/requests/graphql/query/velorum_query_spec.rb rename to spec/requests/graphql/query/ai_query_spec.rb index 71baa6b9..4d91c483 100644 --- a/spec/requests/graphql/query/velorum_query_spec.rb +++ b/spec/requests/graphql/query/ai_query_spec.rb @@ -2,13 +2,13 @@ require 'rails_helper' -RSpec.describe 'velorum query' do +RSpec.describe 'ai query' do include GraphqlHelpers let(:query) do <<~QUERY query { - velorum { + ai { enabled models { identifier @@ -49,11 +49,11 @@ allow(client).to receive(:models).and_return(models_response) end - it 'proxies models from Velorum through gRPC' do + it 'proxies models from AI through gRPC' do post_graphql(query, current_user: current_user) - expect(graphql_data_at(:velorum, :enabled)).to be(true) - expect(graphql_data_at(:velorum, :models)).to contain_exactly( + expect(graphql_data_at(:ai, :enabled)).to be(true) + expect(graphql_data_at(:ai, :models)).to contain_exactly( { 'identifier' => 'gpt-5', 'name' => 'GPT-5', @@ -70,7 +70,7 @@ expect(client).to have_received(:models) end - context 'when Velorum is disabled' do + context 'when AI is disabled' do before do allow(Sagittarius::Configuration).to receive(:config) .and_return(velorum: { enabled: false }) @@ -79,8 +79,8 @@ it 'returns disabled state and an empty model list without creating a Velorum client' do post_graphql(query, current_user: current_user) - expect(graphql_data_at(:velorum, :enabled)).to be(false) - expect(graphql_data_at(:velorum, :models)).to eq([]) + expect(graphql_data_at(:ai, :enabled)).to be(false) + expect(graphql_data_at(:ai, :models)).to eq([]) expect(graphql_errors).to be_nil expect(Sagittarius::Velorum::Client).not_to have_received(:new) end diff --git a/spec/requests/graphql/subscription/ai/generate_flow_spec.rb b/spec/requests/graphql/subscription/ai/generate_flow_spec.rb new file mode 100644 index 00000000..6b453332 --- /dev/null +++ b/spec/requests/graphql/subscription/ai/generate_flow_spec.rb @@ -0,0 +1,246 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'aiGenerateFlow Subscription', type: :channel do + include AuthenticationHelpers + include ActionCable::Channel::TestCase::Behavior + + include_context 'with graphql subscription support' + + tests GraphqlChannel + + let(:user) { create(:user) } + let(:token) { "Session #{authorization_token(user)}" } + let(:execution_identifier) { SecureRandom.uuid } + let(:function_definition) { create(:function_definition, identifier: 'sum') } + let(:parameter_definition) do + create( + :parameter_definition, + function_definition: function_definition, + runtime_parameter_definition: create( + :runtime_parameter_definition, + runtime_function_definition: function_definition.runtime_function_definition + ) + ) + end + let(:flow) do + { + name: 'Generated flow', + type: 'default', + starting_node_id: 'generated-1', + settings: [ + { + id: 'generated-setting-1', + flow_setting_id: 'region', + value: 'eu', + cast: 'string', + } + ], + nodes: [ + { + id: 'generated-1', + function_definition: function_definition, + next_node_id: nil, + parameters: [ + { + id: 'generated-parameter-1-1', + parameter_definition: parameter_definition, + cast: nil, + value: 1, + }, + { + id: 'generated-parameter-1-2', + parameter_definition: parameter_definition, + cast: nil, + value: { + generated_value_type: :reference_value, + id: 'generated-parameter-1-2-reference', + node_function_id: 'generated-1', + parameter_index: 1, + input_index: 2, + input_type_identifier: nil, + reference_path: [ + { + id: 'generated-parameter-1-2-reference-path-1', + path: 'result', + array_index: nil, + } + ], + }, + }, + { + id: 'generated-parameter-1-3', + parameter_definition: parameter_definition, + cast: nil, + value: { + generated_value_type: :sub_flow_value, + starting_node_id: 'generated-1', + function_identifier: 'sum', + signature: '(): undefined', + settings: [ + { + identifier: 'region', + default_value: 'eu', + optional: false, + hidden: true, + } + ], + }, + } + ], + } + ], + } + end + let(:subscription_query) do + <<~GQL + subscription($executionIdentifier: String!) { + aiGenerateFlow(executionIdentifier: $executionIdentifier) { + flow { + name + type + startingNodeId + settings { + id + flowSettingId + value + cast + } + nodes { + id + functionDefinition { + id + identifier + } + nextNodeId + parameters { + id + parameterDefinition { + id + } + cast + value { + __typename + ... on LiteralValue { + value + } + ... on AiGenerationReferenceValue { + nodeFunctionId + parameterIndex + inputIndex + referencePath { + path + arrayIndex + } + } + ... on AiGenerationSubFlowValue { + startingNodeId + signature + settings { + identifier + defaultValue + optional + hidden + } + } + } + } + } + } + } + } + GQL + end + + before do + subscribe(token: token) + end + + it 'delivers a generated flow for the matching execution identifier and closes the subscription' do + perform :execute, + query: subscription_query, + variables: { executionIdentifier: execution_identifier } + + SubscriptionTriggers.ai_generate_flow(execution_identifier, flow) + + expect(transmissions.last).to include('more' => false) + expect(transmissions.last.dig('result', 'data', 'aiGenerateFlow')).to eq( + 'flow' => { + 'name' => 'Generated flow', + 'type' => 'default', + 'startingNodeId' => 'gid://sagittarius/NodeFunction/generated-1', + 'settings' => [ + { + 'id' => 'gid://sagittarius/FlowSetting/generated-setting-1', + 'flowSettingId' => 'region', + 'value' => 'eu', + 'cast' => 'string', + } + ], + 'nodes' => [ + { + 'id' => 'gid://sagittarius/NodeFunction/generated-1', + 'functionDefinition' => { + 'id' => function_definition.to_global_id.to_s, + 'identifier' => 'sum', + }, + 'nextNodeId' => nil, + 'parameters' => [ + { + 'id' => 'gid://sagittarius/NodeParameter/generated-parameter-1-1', + 'parameterDefinition' => { + 'id' => parameter_definition.to_global_id.to_s, + }, + 'cast' => nil, + 'value' => { + '__typename' => 'LiteralValue', + 'value' => 1, + }, + }, + { + 'id' => 'gid://sagittarius/NodeParameter/generated-parameter-1-2', + 'parameterDefinition' => { + 'id' => parameter_definition.to_global_id.to_s, + }, + 'cast' => nil, + 'value' => { + '__typename' => 'AiGenerationReferenceValue', + 'nodeFunctionId' => 'gid://sagittarius/NodeFunction/generated-1', + 'parameterIndex' => 1, + 'inputIndex' => 2, + 'referencePath' => [ + { + 'path' => 'result', + 'arrayIndex' => nil, + } + ], + }, + }, + { + 'id' => 'gid://sagittarius/NodeParameter/generated-parameter-1-3', + 'parameterDefinition' => { + 'id' => parameter_definition.to_global_id.to_s, + }, + 'cast' => nil, + 'value' => { + '__typename' => 'AiGenerationSubFlowValue', + 'startingNodeId' => 'gid://sagittarius/NodeFunction/generated-1', + 'signature' => '(): undefined', + 'settings' => [ + { + 'identifier' => 'region', + 'defaultValue' => 'eu', + 'optional' => false, + 'hidden' => true, + } + ], + }, + } + ], + } + ], + } + ) + end +end diff --git a/spec/requests/graphql/subscription/velorum/generate_flow_spec.rb b/spec/requests/graphql/subscription/velorum/generate_flow_spec.rb deleted file mode 100644 index 92dd311a..00000000 --- a/spec/requests/graphql/subscription/velorum/generate_flow_spec.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe 'velorumGenerateFlow Subscription', type: :channel do - include AuthenticationHelpers - include ActionCable::Channel::TestCase::Behavior - - include_context 'with graphql subscription support' - - tests GraphqlChannel - - let(:user) { create(:user) } - let(:token) { "Session #{authorization_token(user)}" } - let(:execution_identifier) { SecureRandom.uuid } - let(:flow) { { name: 'Generated flow', type: 'default', nodes: [] } } - let(:subscription_query) do - <<~GQL - subscription($executionIdentifier: String!) { - velorumGenerateFlow(executionIdentifier: $executionIdentifier) { - flow - } - } - GQL - end - - before do - subscribe(token: token) - end - - it 'delivers a generated flow for the matching execution identifier and closes the subscription' do - perform :execute, - query: subscription_query, - variables: { executionIdentifier: execution_identifier } - - SubscriptionTriggers.velorum_generate_flow(execution_identifier, flow) - - expect(transmissions.last).to include('more' => false) - expect(transmissions.last.dig('result', 'data', 'velorumGenerateFlow')).to eq( - 'flow' => { 'name' => 'Generated flow', 'type' => 'default', 'nodes' => [] } - ) - end -end diff --git a/spec/services/velorum/generate_flow_service_spec.rb b/spec/services/velorum/generate_flow_service_spec.rb index 96086504..46be8c9d 100644 --- a/spec/services/velorum/generate_flow_service_spec.rb +++ b/spec/services/velorum/generate_flow_service_spec.rb @@ -158,6 +158,24 @@ end end + context 'when Velorum returns a gRPC error' do + before do + allow(client).to receive(:prompt).and_raise( + GRPC::BadStatus.new_status_exception(GRPC::Core::StatusCodes::INTERNAL, 'Unexpected generation error') + ) + end + + it 'returns an error response' do + expect(service_response).to be_error + expect(service_response.message).to eq('Flow generation failed') + expect(service_response.payload[:error_code]).to eq(:flow_generation_failed) + expect(service_response.payload[:details]).to include( + grpc_code: GRPC::Core::StatusCodes::INTERNAL, + grpc_details: 'Unexpected generation error' + ) + end + end + context 'when Velorum is disabled' do subject(:service_response) do described_class.new( diff --git a/spec/services/velorum/generation_flow_serializer_spec.rb b/spec/services/velorum/generation_flow_serializer_spec.rb index 74b4b370..0e74697b 100644 --- a/spec/services/velorum/generation_flow_serializer_spec.rb +++ b/spec/services/velorum/generation_flow_serializer_spec.rb @@ -3,7 +3,37 @@ require 'rails_helper' RSpec.describe Velorum::GenerationFlowSerializer do + let(:runtime) { create(:runtime) } + let(:project) { create(:namespace_project, primary_runtime: runtime) } + it 'serializes a generated flow into frontend-consumable JSON data' do + runtime_function_definition = create(:runtime_function_definition, runtime: runtime, runtime_name: 'sum') + function_definition = create( + :function_definition, + runtime: runtime, + runtime_function_definition: runtime_function_definition, + identifier: 'sum-function' + ) + first_runtime_parameter = create( + :runtime_parameter_definition, + runtime_function_definition: runtime_function_definition, + runtime_name: 'left' + ) + second_runtime_parameter = create( + :runtime_parameter_definition, + runtime_function_definition: runtime_function_definition, + runtime_name: 'right' + ) + create( + :parameter_definition, + function_definition: function_definition, + runtime_parameter_definition: second_runtime_parameter + ) + first_parameter_definition = create( + :parameter_definition, + function_definition: function_definition, + runtime_parameter_definition: first_runtime_parameter + ) flow = Tucana::Shared::GenerationFlow.new( name: 'Generated flow', type: 'default', @@ -20,20 +50,132 @@ ] ) - expect(described_class.new(flow).to_h).to include( + expect(described_class.new(flow, project: project).to_h).to include( name: 'Generated flow', type: 'default', + starting_node_id: 'generated-1', nodes: [ a_hash_including( - function_identifier: 'sum', + id: 'generated-1', + function_definition: function_definition, parameters: [ a_hash_including( - parameter_identifier: 'left', - value: { literal_value: 1 } + id: 'generated-parameter-1-1', + parameter_definition: first_parameter_definition, + value: 1 ) ] ) ] ) end + + it 'maps generated node IDs into references and inferred next-node links' do + flow = Tucana::Shared::GenerationFlow.new( + name: 'Generated flow', + type: 'default', + node_functions: [ + Tucana::Shared::NodeFunction.new( + runtime_function_id: 'input', + parameters: [] + ), + Tucana::Shared::NodeFunction.new( + runtime_function_id: 'output', + parameters: [ + Tucana::Shared::NodeParameter.new( + runtime_parameter_id: 'value', + value: Tucana::Shared::NodeValue.new( + reference_value: Tucana::Shared::ReferenceValue.new( + node_id: 0, + paths: [Tucana::Shared::ReferencePath.new(path: 'result')] + ) + ) + ) + ] + ) + ] + ) + + serialized = described_class.new(flow).to_h + + expect(serialized[:nodes][0]).to include(id: 'generated-1', next_node_id: 'generated-2') + expect(serialized[:nodes][1]).to include(id: 'generated-2', next_node_id: nil) + expect(serialized.dig(:nodes, 1, :parameters, 0, :value)).to include( + generated_value_type: :reference_value, + node_function_id: nil, + reference_path: [ + include(path: 'result', array_index: nil) + ] + ) + end + + it 'maps gRPC reference variants and sub-flow values to flow-shaped objects' do + flow = Tucana::Shared::GenerationFlow.new( + node_functions: [ + Tucana::Shared::NodeFunction.new( + database_id: 10, + runtime_function_id: 'output', + parameters: [ + Tucana::Shared::NodeParameter.new( + database_id: 20, + runtime_parameter_id: 'flow-input', + value: Tucana::Shared::NodeValue.new( + reference_value: Tucana::Shared::ReferenceValue.new( + flow_input: Tucana::Shared::FlowInput.new, + paths: [Tucana::Shared::ReferencePath.new(path: 'input')] + ) + ) + ), + Tucana::Shared::NodeParameter.new( + runtime_parameter_id: 'input-type', + value: Tucana::Shared::NodeValue.new( + reference_value: Tucana::Shared::ReferenceValue.new( + input_type: Tucana::Shared::InputType.new( + node_id: 10, + parameter_index: 1, + input_index: 2 + ) + ) + ) + ), + Tucana::Shared::NodeParameter.new( + runtime_parameter_id: 'sub-flow', + value: Tucana::Shared::NodeValue.new( + sub_flow: Tucana::Shared::SubFlow.new( + function_identifier: 'helper', + signature: '(): undefined' + ) + ) + ) + ] + ) + ] + ) + + serialized = described_class.new(flow).to_h + + expect(serialized).not_to have_key(:node_functions) + expect(serialized.dig(:nodes, 0)).to include(id: '10') + expect(serialized.dig(:nodes, 0, :parameters, 0)).to include(id: 20) + expect(serialized.dig(:nodes, 0, :parameters, 0, :value)).to include( + generated_value_type: :reference_value, + node_function_id: nil, + reference_path: [ + include(path: 'input', array_index: nil) + ] + ) + expect(serialized.dig(:nodes, 0, :parameters, 1, :value)).to include( + generated_value_type: :reference_value, + node_function_id: '10', + parameter_index: 1, + input_index: 2 + ) + expect(serialized.dig(:nodes, 0, :parameters, 2, :value)).to include( + generated_value_type: :sub_flow_value, + starting_node_id: nil, + function_identifier: 'helper', + signature: '(): undefined', + settings: [] + ) + end end diff --git a/test.js b/test.js new file mode 100644 index 00000000..cdb6b9d4 --- /dev/null +++ b/test.js @@ -0,0 +1,190 @@ +const GRAPHQL_URL = process.env.GRAPHQL_URL || 'http://localhost:3000/graphql'; +const CABLE_URL = process.env.CABLE_URL || 'ws://localhost:3000/cable'; +const TOKEN = process.env.TOKEN || 'Admin'; +const AUTHORIZATION = TOKEN.includes(' ') ? TOKEN : `Session ${TOKEN}`; + +const GENERATE_FIELD = process.env.GENERATE_FIELD || 'aiGenerateFlow'; +const ID_FIELD = process.env.ID_FIELD || (GENERATE_FIELD === 'aiGenerateFlow' ? 'executionIdentifier' : 'id'); +const SUBSCRIPTION_ID_ARG = process.env.SUBSCRIPTION_ID_ARG || ID_FIELD; +const INPUT_TYPE = process.env.INPUT_TYPE || (GENERATE_FIELD === 'aiGenerateFlow' + ? 'AiGenerateFlowInput' + : 'VelorumGenerateFlowInput'); +const FLOW_SELECTION = process.env.FLOW_SELECTION || (GENERATE_FIELD === 'aiGenerateFlow' + ? `flow { + name + type + startingNodeId + settings { + id + flowSettingId + value + cast + } + nodes { + id + functionDefinition { + id + identifier + } + nextNodeId + parameters { + id + parameterDefinition { + id + identifier + } + cast + value { + __typename + ... on LiteralValue { + value + } + ... on AiGenerationReferenceValue { + nodeFunctionId + parameterIndex + inputIndex + referencePath { + path + arrayIndex + } + } + ... on AiGenerationSubFlowValue { + startingNodeId + signature + settings { + identifier + defaultValue + optional + hidden + } + } + } + } + } + }` + : 'flow'); + +const PROJECT_ID = process.env.PROJECT_ID || 'gid://sagittarius/NamespaceProject/1'; +const MODEL_IDENTIFIER = process.env.MODEL_IDENTIFIER || 'gpt-oss-120b'; +const PROMPT = process.env.PROMPT || 'Create a simple flow that fetches data from the GitHub issue API.'; + +const WebSocketImpl = globalThis.WebSocket || require('ws'); +const identifier = JSON.stringify({ channel: 'GraphqlChannel', token: AUTHORIZATION }); + +async function createGeneration() { + const mutation = ` + mutation GenerateFlow($input: ${INPUT_TYPE}!) { + ${GENERATE_FIELD}(input: $input) { + ${ID_FIELD} + errors { + errorCode + details { + ... on MessageError { message } + ... on ActiveModelError { attribute type } + } + } + } + } + `; + + const response = await fetch(GRAPHQL_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: AUTHORIZATION, + }, + body: JSON.stringify({ + query: mutation, + variables: { + input: { + projectId: PROJECT_ID, + prompt: PROMPT, + modelIdentifier: MODEL_IDENTIFIER, + }, + }, + }), + }); + + const rawBody = await response.text(); + let body; + try { + body = rawBody ? JSON.parse(rawBody) : null; + } catch (error) { + throw new Error(`Mutation returned non-JSON HTTP ${response.status}: ${rawBody}`); + } + + if (!response.ok || body === null || typeof body !== 'object') { + throw new Error(`Mutation returned HTTP ${response.status}: ${rawBody || ''}`); + } + + console.log('Mutation response:', JSON.stringify(body, null, 2)); + + if (body.errors?.length) { + throw new Error(`GraphQL mutation failed: ${JSON.stringify(body.errors)}`); + } + + const payload = body.data?.[GENERATE_FIELD]; + if (payload?.errors?.length) { + throw new Error(`Generation mutation returned errors: ${JSON.stringify(payload.errors)}`); + } + + const id = payload?.[ID_FIELD]; + if (!id) { + throw new Error(`Generation mutation did not return ${ID_FIELD}`); + } + + return id; +} + +function subscribeToGeneration(id) { + const ws = new WebSocketImpl(CABLE_URL); + + ws.onopen = () => { + ws.send(JSON.stringify({ + command: 'subscribe', + identifier, + })); + }; + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.type === 'ping') return; + + console.log('Received:', JSON.stringify(data, null, 2)); + + if (data.type === 'confirm_subscription') { + ws.send(JSON.stringify({ + command: 'message', + identifier, + data: JSON.stringify({ + action: 'execute', + query: ` + subscription GeneratedFlow($id: String!) { + ${GENERATE_FIELD}(${SUBSCRIPTION_ID_ARG}: $id) { + ${FLOW_SELECTION} + } + } + `, + variables: { id }, + }), + })); + } + + if (data.message?.more === false) { + ws.close(); + } + }; + + ws.onerror = (event) => console.error('WS error:', event); + ws.onclose = (event) => console.log('WS closed:', event.code, event.reason); +} + +createGeneration() + .then((id) => { + console.log(`Subscribing to generation id: ${id}`); + subscribeToGeneration(id); + }) + .catch((error) => { + console.error(error); + process.exitCode = 1; + });