raif 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +678 -0
  4. data/Rakefile +20 -0
  5. data/app/assets/builds/raif.css +74 -0
  6. data/app/assets/builds/raif_admin.css +266 -0
  7. data/app/assets/config/raif_manifest.js +1 -0
  8. data/app/assets/javascript/raif/controllers/conversations_controller.js +11 -0
  9. data/app/assets/javascript/raif/stream_actions/raif_scroll_to_bottom.js +12 -0
  10. data/app/assets/javascript/raif.js +10 -0
  11. data/app/assets/stylesheets/raif/admin/conversation.scss +64 -0
  12. data/app/assets/stylesheets/raif/loader.scss +85 -0
  13. data/app/assets/stylesheets/raif.scss +1 -0
  14. data/app/assets/stylesheets/raif_admin.scss +299 -0
  15. data/app/controllers/raif/admin/agents_controller.rb +17 -0
  16. data/app/controllers/raif/admin/application_controller.rb +20 -0
  17. data/app/controllers/raif/admin/conversations_controller.rb +17 -0
  18. data/app/controllers/raif/admin/model_completions_controller.rb +17 -0
  19. data/app/controllers/raif/admin/model_tool_invocations_controller.rb +17 -0
  20. data/app/controllers/raif/admin/tasks_controller.rb +23 -0
  21. data/app/controllers/raif/application_controller.rb +20 -0
  22. data/app/controllers/raif/conversation_entries_controller.rb +60 -0
  23. data/app/controllers/raif/conversations_controller.rb +58 -0
  24. data/app/helpers/raif/application_helper.rb +7 -0
  25. data/app/helpers/raif/shared/conversations_helper.rb +13 -0
  26. data/app/jobs/raif/application_job.rb +8 -0
  27. data/app/jobs/raif/conversation_entry_job.rb +30 -0
  28. data/app/models/raif/agent.rb +133 -0
  29. data/app/models/raif/agents/native_tool_calling_agent.rb +127 -0
  30. data/app/models/raif/agents/re_act_agent.rb +121 -0
  31. data/app/models/raif/agents/re_act_step.rb +33 -0
  32. data/app/models/raif/application_record.rb +14 -0
  33. data/app/models/raif/concerns/boolean_timestamp.rb +69 -0
  34. data/app/models/raif/concerns/has_available_model_tools.rb +13 -0
  35. data/app/models/raif/concerns/has_llm.rb +19 -0
  36. data/app/models/raif/concerns/has_requested_language.rb +20 -0
  37. data/app/models/raif/concerns/invokes_model_tools.rb +13 -0
  38. data/app/models/raif/concerns/llm_response_parsing.rb +44 -0
  39. data/app/models/raif/conversation.rb +67 -0
  40. data/app/models/raif/conversation_entry.rb +85 -0
  41. data/app/models/raif/llm.rb +88 -0
  42. data/app/models/raif/llms/anthropic.rb +120 -0
  43. data/app/models/raif/llms/bedrock_claude.rb +134 -0
  44. data/app/models/raif/llms/open_ai.rb +259 -0
  45. data/app/models/raif/model_completion.rb +28 -0
  46. data/app/models/raif/model_tool.rb +69 -0
  47. data/app/models/raif/model_tool_invocation.rb +43 -0
  48. data/app/models/raif/model_tools/agent_final_answer.rb +46 -0
  49. data/app/models/raif/model_tools/fetch_url.rb +57 -0
  50. data/app/models/raif/model_tools/wikipedia_search.rb +78 -0
  51. data/app/models/raif/task.rb +137 -0
  52. data/app/models/raif/user_tool_invocation.rb +29 -0
  53. data/app/views/layouts/raif/admin.html.erb +98 -0
  54. data/app/views/raif/admin/agents/_agent.html.erb +18 -0
  55. data/app/views/raif/admin/agents/_conversation_message.html.erb +15 -0
  56. data/app/views/raif/admin/agents/index.html.erb +33 -0
  57. data/app/views/raif/admin/agents/show.html.erb +131 -0
  58. data/app/views/raif/admin/conversations/_conversation.html.erb +7 -0
  59. data/app/views/raif/admin/conversations/_conversation_entry.html.erb +34 -0
  60. data/app/views/raif/admin/conversations/index.html.erb +32 -0
  61. data/app/views/raif/admin/conversations/show.html.erb +56 -0
  62. data/app/views/raif/admin/model_completions/_model_completion.html.erb +9 -0
  63. data/app/views/raif/admin/model_completions/index.html.erb +34 -0
  64. data/app/views/raif/admin/model_completions/show.html.erb +117 -0
  65. data/app/views/raif/admin/model_tool_invocations/_model_tool_invocation.html.erb +16 -0
  66. data/app/views/raif/admin/model_tool_invocations/index.html.erb +33 -0
  67. data/app/views/raif/admin/model_tool_invocations/show.html.erb +66 -0
  68. data/app/views/raif/admin/tasks/_task.html.erb +19 -0
  69. data/app/views/raif/admin/tasks/index.html.erb +49 -0
  70. data/app/views/raif/admin/tasks/show.html.erb +176 -0
  71. data/app/views/raif/conversation_entries/_conversation_entry.html.erb +26 -0
  72. data/app/views/raif/conversation_entries/_form.html.erb +25 -0
  73. data/app/views/raif/conversation_entries/_form_with_available_tools.html.erb +4 -0
  74. data/app/views/raif/conversation_entries/_form_with_user_tool_invocation.html.erb +18 -0
  75. data/app/views/raif/conversation_entries/_message.html.erb +17 -0
  76. data/app/views/raif/conversation_entries/_model_response_avatar.html.erb +1 -0
  77. data/app/views/raif/conversation_entries/_user_avatar.html.erb +1 -0
  78. data/app/views/raif/conversation_entries/create.turbo_stream.erb +11 -0
  79. data/app/views/raif/conversation_entries/new.turbo_stream.erb +6 -0
  80. data/app/views/raif/conversations/_available_user_tools.html.erb +11 -0
  81. data/app/views/raif/conversations/_full_conversation.html.erb +15 -0
  82. data/app/views/raif/conversations/show.html.erb +1 -0
  83. data/config/i18n-tasks.yml +181 -0
  84. data/config/importmap.rb +6 -0
  85. data/config/initializers/pagy.rb +14 -0
  86. data/config/locales/admin.en.yml +91 -0
  87. data/config/locales/en.yml +50 -0
  88. data/config/routes.rb +22 -0
  89. data/db/migrate/20250224234252_create_raif_tables.rb +114 -0
  90. data/lib/generators/raif/agent/agent_generator.rb +22 -0
  91. data/lib/generators/raif/agent/templates/agent.rb.tt +28 -0
  92. data/lib/generators/raif/conversation/conversation_generator.rb +27 -0
  93. data/lib/generators/raif/conversation/templates/conversation.rb.tt +37 -0
  94. data/lib/generators/raif/install/install_generator.rb +31 -0
  95. data/lib/generators/raif/install/templates/initializer.rb +81 -0
  96. data/lib/generators/raif/model_tool/model_tool_generator.rb +27 -0
  97. data/lib/generators/raif/model_tool/templates/model_tool.rb.tt +74 -0
  98. data/lib/generators/raif/task/task_generator.rb +28 -0
  99. data/lib/generators/raif/task/templates/application_task.rb.tt +7 -0
  100. data/lib/generators/raif/task/templates/task.rb.tt +52 -0
  101. data/lib/generators/raif/views_generator.rb +22 -0
  102. data/lib/raif/configuration.rb +82 -0
  103. data/lib/raif/default_llms.rb +37 -0
  104. data/lib/raif/engine.rb +86 -0
  105. data/lib/raif/errors/action_not_authorized_error.rb +8 -0
  106. data/lib/raif/errors/anthropic/api_error.rb +10 -0
  107. data/lib/raif/errors/invalid_config_error.rb +8 -0
  108. data/lib/raif/errors/invalid_conversation_type_error.rb +8 -0
  109. data/lib/raif/errors/invalid_user_tool_type_error.rb +8 -0
  110. data/lib/raif/errors/open_ai/api_error.rb +10 -0
  111. data/lib/raif/errors/open_ai/json_schema_error.rb +10 -0
  112. data/lib/raif/errors.rb +9 -0
  113. data/lib/raif/languages.rb +33 -0
  114. data/lib/raif/rspec.rb +7 -0
  115. data/lib/raif/utils/html_to_markdown_converter.rb +7 -0
  116. data/lib/raif/utils/readable_content_extractor.rb +61 -0
  117. data/lib/raif/utils.rb +6 -0
  118. data/lib/raif/version.rb +5 -0
  119. data/lib/raif.rb +65 -0
  120. data/lib/tasks/raif_tasks.rake +6 -0
  121. metadata +294 -0
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif
4
+ class Agent < ApplicationRecord
5
+ include Raif::Concerns::HasLlm
6
+ include Raif::Concerns::HasRequestedLanguage
7
+ include Raif::Concerns::HasAvailableModelTools
8
+ include Raif::Concerns::InvokesModelTools
9
+
10
+ belongs_to :creator, polymorphic: true
11
+
12
+ has_many :raif_model_completions, as: :source, dependent: :destroy, class_name: "Raif::ModelCompletion"
13
+
14
+ after_initialize -> { self.available_model_tools ||= [] }
15
+ after_initialize -> { self.conversation_history ||= [] }
16
+
17
+ boolean_timestamp :started_at
18
+ boolean_timestamp :completed_at
19
+ boolean_timestamp :failed_at
20
+
21
+ validates :type, inclusion: { in: ->{ Raif.config.agent_types } }
22
+ validates :task, presence: true
23
+ validates :system_prompt, presence: true
24
+ validates :max_iterations, presence: true, numericality: { greater_than: 0 }
25
+ validates :available_model_tools, length: {
26
+ minimum: 1,
27
+ message: ->(_object, _data) {
28
+ I18n.t("raif.agents.errors.available_model_tools.too_short")
29
+ }
30
+ }
31
+
32
+ before_validation -> { self.system_prompt ||= build_system_prompt }, on: :create
33
+
34
+ attr_accessor :on_conversation_history_entry
35
+
36
+ # Runs the agent and returns a Raif::Agent.
37
+ # If a block is given, it will be called each time a new entry is added to the agent's conversation history.
38
+ # The block will receive the Raif::Agent and the new entry as arguments:
39
+ # agent = Raif::Agent.new(
40
+ # task: task,
41
+ # tools: [Raif::ModelTools::WikipediaSearch, Raif::ModelTools::FetchUrl],
42
+ # creator: creator
43
+ # )
44
+ #
45
+ # agent.run! do |conversation_history_entry|
46
+ # Turbo::StreamsChannel.broadcast_append_to(
47
+ # :my_agent_channel,
48
+ # target: "agent-progress",
49
+ # partial: "my_partial_displaying_agent_progress",
50
+ # locals: { agent: agent, conversation_history_entry: conversation_history_entry }
51
+ # )
52
+ # end
53
+ #
54
+ # The conversation_history_entry will be a hash with "role" and "content" keys:
55
+ # { "role" => "assistant", "content" => "a message here" }
56
+ #
57
+ # @param block [Proc] Optional block to be called each time a new entry to the agent's conversation history is generated
58
+ # @return [Raif::Agent] The agent that was created and run
59
+ def run!(&block)
60
+ self.on_conversation_history_entry = block_given? ? block : nil
61
+ self.started_at = Time.current
62
+ self.available_model_tools += ["Raif::ModelTools::AgentFinalAnswer"] unless available_model_tools.include?("Raif::ModelTools::AgentFinalAnswer")
63
+ save!
64
+
65
+ logger.debug <<~DEBUG
66
+ --------------------------------
67
+ Starting Agent Run
68
+ --------------------------------
69
+ System Prompt:
70
+ #{system_prompt}
71
+
72
+ Task: #{task}
73
+ DEBUG
74
+
75
+ add_conversation_history_entry({ role: "user", content: task })
76
+
77
+ while iteration_count < max_iterations
78
+ update_columns(iteration_count: iteration_count + 1)
79
+
80
+ model_completion = llm.chat(
81
+ messages: conversation_history,
82
+ source: self,
83
+ system_prompt: system_prompt,
84
+ available_model_tools: native_model_tools
85
+ )
86
+
87
+ logger.debug <<~DEBUG
88
+ --------------------------------
89
+ Agent iteration #{iteration_count}
90
+ Messages:
91
+ #{JSON.pretty_generate(conversation_history)}
92
+
93
+ Response:
94
+ #{model_completion.raw_response}
95
+ --------------------------------
96
+ DEBUG
97
+
98
+ process_iteration_model_completion(model_completion)
99
+ break if final_answer.present?
100
+ end
101
+
102
+ completed!
103
+ final_answer
104
+ rescue StandardError => e
105
+ self.failed_at = Time.current
106
+ self.failure_reason = e.message
107
+ save!
108
+
109
+ raise
110
+ end
111
+
112
+ private
113
+
114
+ def process_iteration_model_completion(model_completion)
115
+ raise NotImplementedError, "#{self.class.name} must implement execute_agent_iteration"
116
+ end
117
+
118
+ def native_model_tools
119
+ # no-op by default
120
+ end
121
+
122
+ def add_conversation_history_entry(entry)
123
+ entry_stringified = entry.stringify_keys
124
+ conversation_history << entry_stringified
125
+ on_conversation_history_entry.call(entry_stringified) if on_conversation_history_entry.present?
126
+ end
127
+
128
+ def build_system_prompt
129
+ raise NotImplementedError, "Subclasses of Raif::Agent must implement build_system_prompt"
130
+ end
131
+
132
+ end
133
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif
4
+ module Agents
5
+ class NativeToolCallingAgent < Raif::Agent
6
+ validate :ensure_llm_supports_native_tool_use
7
+
8
+ def build_system_prompt
9
+ <<~PROMPT.strip
10
+ You are an AI agent that follows the ReAct (Reasoning + Acting) framework to complete tasks step by step using tool/function calls.
11
+
12
+ At each step, you must:
13
+ 1. Think about what to do next.
14
+ 2. Choose and invoke exactly one tool/function call based on that thought.
15
+ 3. Observe the results of the tool/function call.
16
+ 4. Use the results to update your thought process.
17
+ 5. Repeat steps 1-4 until the task is complete.
18
+ 6. Provide a final answer to the user's request.
19
+
20
+ For your final answer:
21
+ - Use the agent_final_answer tool/function with your complete answer as the "final_answer" parameter.
22
+ - Your answer should be comprehensive and directly address the user's request.
23
+
24
+ Guidelines
25
+ - Always think step by step
26
+ - Be concise in your reasoning but thorough in your analysis
27
+ - If a tool returns an error, try to understand why and adjust your approach
28
+ - If you're unsure about something, explain your uncertainty, but do not make things up
29
+ - Always provide a final answer that directly addresses the user's request
30
+
31
+ Remember: Your goal is to be helpful, accurate, and efficient in solving the user's request.#{system_prompt_language_preference}
32
+ PROMPT
33
+ end
34
+
35
+ private
36
+
37
+ def native_model_tools
38
+ available_model_tools
39
+ end
40
+
41
+ def process_iteration_model_completion(model_completion)
42
+ if model_completion.parsed_response.present?
43
+ add_conversation_history_entry({
44
+ role: "assistant",
45
+ content: model_completion.parsed_response
46
+ })
47
+ end
48
+
49
+ if model_completion.response_tool_calls.blank?
50
+ add_conversation_history_entry({
51
+ role: "assistant",
52
+ content: "<observation>Error: No tool call found. I need make a tool call at each step. Available tools: #{available_model_tools_map.keys.join(", ")}</observation>" # rubocop:disable Layout/LineLength
53
+ })
54
+ return
55
+ end
56
+
57
+ tool_call = model_completion.response_tool_calls.first
58
+
59
+ unless tool_call["name"] && tool_call["arguments"]
60
+ add_conversation_history_entry({
61
+ role: "assistant",
62
+ content: "<observation>Error: Invalid action specified. Please provide a valid action, formatted as a JSON object with 'tool' and 'arguments' keys.</observation>" # rubocop:disable Layout/LineLength
63
+ })
64
+ return
65
+ end
66
+
67
+ tool_name = tool_call["name"]
68
+ tool_arguments = tool_call["arguments"]
69
+
70
+ # Add assistant's response to conversation history (without the actual tool calls)
71
+ # add_conversation_history_entry({
72
+ # role: "assistant",
73
+ # content: "<thought>I need to use the #{tool_name} tool to help with this task.</thought>"
74
+ # })
75
+
76
+ # Check if we have a final answer. If yes, we're done.
77
+ if tool_name == "agent_final_answer"
78
+ self.final_answer = tool_arguments["final_answer"]
79
+ add_conversation_history_entry({ role: "assistant", content: "<answer>#{final_answer}</answer>" })
80
+ return
81
+ end
82
+
83
+ # Add the tool call to conversation history
84
+ add_conversation_history_entry({
85
+ role: "assistant",
86
+ content: "<action>#{JSON.pretty_generate(tool_call)}</action>"
87
+ })
88
+
89
+ # Find the tool class and process it
90
+ tool_klass = available_model_tools_map[tool_name]
91
+
92
+ # The model tried to use a tool that doesn't exist
93
+ unless tool_klass
94
+ add_conversation_history_entry({
95
+ role: "assistant",
96
+ content: "<observation>Error: Tool '#{tool_name}' not found. Available tools: #{available_model_tools_map.keys.join(", ")}</observation>"
97
+ })
98
+ return
99
+ end
100
+
101
+ unless JSON::Validator.validate(tool_klass.tool_arguments_schema, tool_arguments)
102
+ add_conversation_history_entry({
103
+ role: "assistant",
104
+ content: "<observation>Error: Invalid tool arguments. Please provide valid arguments for the tool '#{tool_name}'. Tool arguments schema: #{tool_klass.tool_arguments_schema.to_json}</observation>" # rubocop:disable Layout/LineLength
105
+ })
106
+ return
107
+ end
108
+
109
+ # Process the tool and add observation to history
110
+ tool_invocation = tool_klass.invoke_tool(tool_arguments: tool_arguments, source: self)
111
+ observation = tool_klass.observation_for_invocation(tool_invocation)
112
+
113
+ add_conversation_history_entry({
114
+ role: "assistant",
115
+ content: "<observation>#{observation}</observation>"
116
+ })
117
+ end
118
+
119
+ def ensure_llm_supports_native_tool_use
120
+ unless llm.supports_native_tool_use?
121
+ errors.add(:base, "Raif::Agent#llm_model_key must use an LLM that supports native tool use")
122
+ end
123
+ end
124
+
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif
4
+ module Agents
5
+ class ReActAgent < Raif::Agent
6
+
7
+ def build_system_prompt
8
+ <<~PROMPT.strip
9
+ You are an intelligent assistant that follows the ReAct (Reasoning + Acting) framework to complete tasks step by step using tool calls.
10
+
11
+ # Available Tools
12
+ You have access to the following tools:
13
+ #{available_model_tools_map.values.map(&:description_for_llm).join("\n---\n")}
14
+ # Your Responses
15
+ Your responses should follow this structure & format:
16
+ <thought>Your step-by-step reasoning about what to do</thought>
17
+ <action>JSON object with "tool" and "arguments" keys</action>
18
+ <observation>Results from the tool, which will be provided to you</observation>
19
+ ... (repeat Thought/Action/Observation as needed until the task is complete)
20
+ <thought>Final reasoning based on all observations</thought>
21
+ <answer>Your final response to the user</answer>
22
+
23
+ # How to Use Tools
24
+ When you need to use a tool:
25
+ 1. Identify which tool is appropriate for the task
26
+ 2. Format your tool call using JSON with the required arguments and place it in the <action> tag
27
+ 3. Here is an example: <action>{"tool": "tool_name", "arguments": {...}}</action>
28
+
29
+ # Guidelines
30
+ - Always think step by step
31
+ - Use tools when appropriate, but don't use tools for tasks you can handle directly
32
+ - Be concise in your reasoning but thorough in your analysis
33
+ - If a tool returns an error, try to understand why and adjust your approach
34
+ - If you're unsure about something, explain your uncertainty, but do not make things up
35
+ - After each thought, make sure to also include an <action> or <answer>
36
+ - Always provide a final answer that directly addresses the user's request
37
+
38
+ Remember: Your goal is to be helpful, accurate, and efficient in solving the user's request.#{system_prompt_language_preference}
39
+ PROMPT
40
+ end
41
+
42
+ private
43
+
44
+ def process_iteration_model_completion(model_completion)
45
+ agent_step = Raif::Agents::ReActStep.new(model_response_text: model_completion.raw_response)
46
+
47
+ # Add the thought to conversation history
48
+ if agent_step.thought
49
+ add_conversation_history_entry({ role: "assistant", content: "<thought>#{agent_step.thought}</thought>" })
50
+ end
51
+
52
+ # If there's an answer, we're done
53
+ if agent_step.answer
54
+ self.final_answer = agent_step.answer
55
+ add_conversation_history_entry({ role: "assistant", content: "<answer>#{agent_step.answer}</answer>" })
56
+ return
57
+ end
58
+
59
+ # If there's an action, execute it
60
+ process_action(agent_step.action) if agent_step.action
61
+ end
62
+
63
+ def process_action(action)
64
+ add_conversation_history_entry({ role: "assistant", content: "<action>#{action}</action>" })
65
+
66
+ # The action should always be a JSON object with "tool" and "arguments" keys
67
+ parsed_action = begin
68
+ JSON.parse(action)
69
+ rescue JSON::ParserError => e
70
+ add_conversation_history_entry({
71
+ role: "assistant",
72
+ content: "<observation>Error parsing action JSON: #{e.message}</observation>"
73
+ })
74
+
75
+ nil
76
+ end
77
+
78
+ return if parsed_action.blank?
79
+
80
+ unless parsed_action["tool"] && parsed_action["arguments"]
81
+ add_conversation_history_entry({
82
+ role: "assistant",
83
+ content: "<observation>Error: Invalid action specified. Please provide a valid action, formatted as a JSON object with 'tool' and 'arguments' keys.</observation>" # rubocop:disable Layout/LineLength
84
+ })
85
+ return
86
+ end
87
+
88
+ tool_name = parsed_action["tool"]
89
+ tool_arguments = parsed_action["arguments"]
90
+ tool_klass = available_model_tools_map[tool_name]
91
+
92
+ # The model tried to use a tool that doesn't exist
93
+ unless tool_klass
94
+ add_conversation_history_entry({
95
+ role: "assistant",
96
+ content: "<observation>Error: Tool '#{tool_name}' not found. Available tools: #{available_model_tools_map.keys.join(", ")}</observation>"
97
+ })
98
+ return
99
+ end
100
+
101
+ unless JSON::Validator.validate(tool_klass.tool_arguments_schema, tool_arguments)
102
+ add_conversation_history_entry({
103
+ role: "assistant",
104
+ content: "<observation>Error: Invalid tool arguments. Please provide valid arguments for the tool '#{tool_name}'. Tool arguments schema: #{tool_klass.tool_arguments_schema.to_json}</observation>" # rubocop:disable Layout/LineLength
105
+ })
106
+ return
107
+ end
108
+
109
+ tool_invocation = tool_klass.invoke_tool(tool_arguments: tool_arguments, source: self)
110
+ observation = tool_klass.observation_for_invocation(tool_invocation)
111
+
112
+ # Add the tool invocation to conversation history
113
+ add_conversation_history_entry({
114
+ role: "assistant",
115
+ content: "<observation>#{observation}</observation>"
116
+ })
117
+ end
118
+
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif
4
+ module Agents
5
+ class ReActStep
6
+ attr_reader :model_response_text
7
+
8
+ def initialize(model_response_text:)
9
+ @model_response_text = model_response_text
10
+ end
11
+
12
+ def thought
13
+ @thought ||= extract_tag_content("thought")
14
+ end
15
+
16
+ def answer
17
+ @answer ||= extract_tag_content("answer")
18
+ end
19
+
20
+ def action
21
+ @action ||= extract_tag_content("action")
22
+ end
23
+
24
+ private
25
+
26
+ def extract_tag_content(tag_name)
27
+ match = model_response_text.match(%r{<#{tag_name}>(.*?)</#{tag_name}>}m)
28
+ match ? match[1].strip : nil
29
+ end
30
+
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Raif::ApplicationRecord < Raif.config.model_superclass.constantize
4
+ include Raif::Concerns::BooleanTimestamp
5
+
6
+ self.abstract_class = true
7
+
8
+ scope :newest_first, -> { order(created_at: :desc) }
9
+ scope :oldest_first, -> { order(created_at: :asc) }
10
+
11
+ def self.table_name_prefix
12
+ "raif_"
13
+ end
14
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif::Concerns::BooleanTimestamp
4
+ extend ActiveSupport::Concern
5
+
6
+ class_methods do
7
+ # Column name should be something like email_abc_disabled_at, or its inverse should be specified
8
+ def boolean_timestamp(column_name, define_inverse_accessors: false)
9
+ if define_inverse_accessors.present?
10
+ unless define_inverse_accessors.is_a?(Symbol) || column_name.end_with?("_disabled_at") || column_name.end_with?("_enabled_at")
11
+ raise ArgumentError, "boolean_timestamp column name (#{column_name}) must end with '_disabled_at' or '_enabled_at'"
12
+ end
13
+
14
+ inverse_boolean_column_name = define_inverse_accessors if define_inverse_accessors.is_a?(Symbol)
15
+ end
16
+
17
+ # Get the boolean version of the column name (e.g. email_abc_disabled)
18
+ boolean_column_name = column_name.to_s.gsub(/_at$/, "")
19
+
20
+ # Define boolean getter (e.g. email_abc_disabled?)
21
+ define_method("#{boolean_column_name}?") do
22
+ send(column_name).present?
23
+ end
24
+ alias_method boolean_column_name, "#{boolean_column_name}?"
25
+
26
+ # Define boolean setter (e.g. email_abc_disabled = true)
27
+ define_method("#{boolean_column_name}=") do |val|
28
+ if val == "1" || val == 1 || val == true || val == "true"
29
+ send("#{column_name}=", Time.current) if send(column_name).nil?
30
+ else
31
+ send("#{column_name}=", nil)
32
+ end
33
+ end
34
+
35
+ # Define bang method to set the value (e.g. email_abc_disabled!)
36
+ define_method("#{boolean_column_name}!") do
37
+ update(column_name => Time.current) if send(column_name).nil?
38
+ end
39
+
40
+ scope boolean_column_name, -> { where.not(table_name => { column_name => nil }) }
41
+
42
+ # Define the inverse getter/setter (e.g. email_abc_enabled?)
43
+ if define_inverse_accessors
44
+ inverse_boolean_column_name ||= if boolean_column_name.end_with?("_disabled")
45
+ boolean_column_name.gsub(/_disabled$/, "_enabled")
46
+ else
47
+ boolean_column_name.gsub(/_enabled$/, "_disabled")
48
+ end
49
+
50
+ # Define boolean getter (e.g. email_abc_enabled)
51
+ define_method("#{inverse_boolean_column_name}?") do
52
+ !send(boolean_column_name)
53
+ end
54
+ alias_method inverse_boolean_column_name, "#{inverse_boolean_column_name}?"
55
+
56
+ # Define boolean setter (e.g. email_abc_enabled = true)
57
+ define_method("#{inverse_boolean_column_name}=") do |val|
58
+ if val == "1" || val == 1 || val == true || val == "true"
59
+ send("#{column_name}=", nil)
60
+ elsif send(column_name).nil?
61
+ send("#{column_name}=", Time.current)
62
+ end
63
+ end
64
+
65
+ scope inverse_boolean_column_name, -> { where(table_name => { column_name => nil }) }
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif::Concerns::HasAvailableModelTools
4
+ extend ActiveSupport::Concern
5
+
6
+ def available_model_tools_map
7
+ @available_model_tools_map ||= available_model_tools&.map do |tool_name|
8
+ tool_klass = tool_name.is_a?(String) ? tool_name.constantize : tool_name
9
+ [tool_klass.tool_name, tool_klass]
10
+ end.to_h
11
+ end
12
+
13
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif::Concerns::HasLlm
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ validates :llm_model_key, presence: true, inclusion: { in: ->{ Raif.available_llm_keys.map(&:to_s) } }
8
+
9
+ before_validation ->{ self.llm_model_key ||= default_llm_model_key }
10
+ end
11
+
12
+ def default_llm_model_key
13
+ Rails.env.test? ? :raif_test_llm : Raif.config.default_llm_model_key
14
+ end
15
+
16
+ def llm
17
+ @llm ||= Raif.llm(llm_model_key.to_sym)
18
+ end
19
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif::Concerns::HasRequestedLanguage
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ validates :requested_language_key, inclusion: { in: Raif.supported_languages, allow_blank: true }
8
+ end
9
+
10
+ def requested_language_name
11
+ @requested_language_name ||= I18n.t("raif.languages.#{requested_language_key}", locale: "en")
12
+ end
13
+
14
+ def system_prompt_language_preference
15
+ return if requested_language_key.blank?
16
+
17
+ "\nYou're collaborating with teammate who speaks #{requested_language_name}. Please respond in #{requested_language_name}."
18
+ end
19
+
20
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif::Concerns::InvokesModelTools
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ has_many :raif_model_tool_invocations,
8
+ class_name: "Raif::ModelToolInvocation",
9
+ as: :source,
10
+ dependent: :destroy
11
+ end
12
+
13
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif::Concerns::LlmResponseParsing
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ normalizes :raw_response, with: ->(text){ text&.strip }
8
+
9
+ enum :response_format, Raif::Llm.valid_response_formats, prefix: true
10
+
11
+ validates :response_format, presence: true, inclusion: { in: response_formats.keys }
12
+ end
13
+
14
+ # Parses the response from the LLM into a structured format, based on the response_format.
15
+ # If the response format is JSON, it will be parsed using JSON.parse.
16
+ # If the response format is HTML, it will be sanitized via ActionController::Base.helpers.sanitize.
17
+ #
18
+ # @return [Object] The parsed response.
19
+ def parsed_response
20
+ return if raw_response.blank?
21
+
22
+ @parsed_response ||= if response_format_json?
23
+ json = raw_response.gsub("```json", "").gsub("```", "")
24
+ JSON.parse(json)
25
+ elsif response_format_html?
26
+ html = raw_response.strip.gsub("```html", "").chomp("```")
27
+ clean_html_fragment(html)
28
+ else
29
+ raw_response.strip
30
+ end
31
+ end
32
+
33
+ def clean_html_fragment(html)
34
+ fragment = Nokogiri::HTML.fragment(html)
35
+
36
+ fragment.traverse do |node|
37
+ if node.text? && node.text.strip.empty?
38
+ node.remove
39
+ end
40
+ end
41
+
42
+ ActionController::Base.helpers.sanitize(fragment.to_html).strip
43
+ end
44
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Raif::Conversation < Raif::ApplicationRecord
4
+ include Raif::Concerns::HasLlm
5
+ include Raif::Concerns::HasRequestedLanguage
6
+ include Raif::Concerns::HasAvailableModelTools
7
+
8
+ belongs_to :creator, polymorphic: true
9
+
10
+ has_many :entries, class_name: "Raif::ConversationEntry", dependent: :destroy, foreign_key: :raif_conversation_id, inverse_of: :raif_conversation
11
+
12
+ validates :type, inclusion: { in: ->{ Raif.config.conversation_types } }
13
+
14
+ after_initialize -> { self.available_model_tools ||= [] }
15
+ after_initialize -> { self.available_user_tools ||= [] }
16
+
17
+ before_validation ->{ self.type ||= "Raif::Conversation" }, on: :create
18
+ before_validation -> { self.system_prompt ||= build_system_prompt }, on: :create
19
+
20
+ def build_system_prompt
21
+ <<~PROMPT
22
+ #{system_prompt_intro}
23
+ #{system_prompt_language_preference}
24
+ PROMPT
25
+ end
26
+
27
+ def system_prompt_intro
28
+ Raif.config.conversation_system_prompt_intro
29
+ end
30
+
31
+ # i18n-tasks-use t('raif.conversation.initial_chat_message')
32
+ def initial_chat_message
33
+ I18n.t("#{self.class.name.underscore.gsub("/", ".")}.initial_chat_message")
34
+ end
35
+
36
+ def prompt_model_for_entry_response(entry:)
37
+ llm.chat(
38
+ messages: llm_messages,
39
+ source: entry,
40
+ response_format: :text,
41
+ system_prompt: system_prompt,
42
+ available_model_tools: available_model_tools
43
+ )
44
+ end
45
+
46
+ def llm_messages
47
+ messages = []
48
+
49
+ entries.oldest_first.includes(:raif_model_tool_invocations).each do |entry|
50
+ messages << { "role" => "user", "content" => entry.user_message }
51
+ next unless entry.completed?
52
+
53
+ messages << { "role" => "assistant", "content" => entry.model_response_message } unless entry.model_response_message.blank?
54
+ entry.raif_model_tool_invocations.each do |tool_invocation|
55
+ messages << { "role" => "assistant", "content" => tool_invocation.as_llm_message }
56
+ messages << { "role" => "assistant", "content" => tool_invocation.result_llm_message } if tool_invocation.result_llm_message.present?
57
+ end
58
+ end
59
+
60
+ messages
61
+ end
62
+
63
+ def available_user_tool_classes
64
+ available_user_tools.map(&:constantize)
65
+ end
66
+
67
+ end