aven 0.0.3

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 (159) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +35 -0
  4. data/Rakefile +19 -0
  5. data/app/assets/stylesheets/aven/application.css +14 -0
  6. data/app/assets/stylesheets/aven/application.tailwind.css +7 -0
  7. data/app/assets/stylesheets/aven/tailwind.css +224 -0
  8. data/app/channels/aven/chat/thread_channel.rb +39 -0
  9. data/app/components/aven/application_view_component.rb +15 -0
  10. data/app/components/aven/views/admin/dashboard/index/component.html.erb +1 -0
  11. data/app/components/aven/views/admin/dashboard/index/component.rb +5 -0
  12. data/app/components/aven/views/articles/edit/component.html.erb +14 -0
  13. data/app/components/aven/views/articles/edit/component.rb +14 -0
  14. data/app/components/aven/views/articles/form/component.html.erb +45 -0
  15. data/app/components/aven/views/articles/form/component.rb +27 -0
  16. data/app/components/aven/views/articles/index/component.html.erb +93 -0
  17. data/app/components/aven/views/articles/index/component.rb +29 -0
  18. data/app/components/aven/views/articles/new/component.html.erb +13 -0
  19. data/app/components/aven/views/articles/new/component.rb +14 -0
  20. data/app/components/aven/views/articles/show/component.html.erb +110 -0
  21. data/app/components/aven/views/articles/show/component.rb +34 -0
  22. data/app/components/aven/views/oauth/error/component.html.erb +44 -0
  23. data/app/components/aven/views/oauth/error/component.rb +30 -0
  24. data/app/components/aven/views/static/index/component.html.erb +17 -0
  25. data/app/components/aven/views/static/index/component.rb +16 -0
  26. data/app/components/aven/views/static/index/controller.js +7 -0
  27. data/app/controllers/aven/admin/base.rb +16 -0
  28. data/app/controllers/aven/admin/dashboard_controller.rb +9 -0
  29. data/app/controllers/aven/agentic/agents_controller.rb +56 -0
  30. data/app/controllers/aven/agentic/documents_controller.rb +51 -0
  31. data/app/controllers/aven/agentic/mcp_controller.rb +124 -0
  32. data/app/controllers/aven/agentic/tools_controller.rb +37 -0
  33. data/app/controllers/aven/ai/text_controller.rb +41 -0
  34. data/app/controllers/aven/application_controller.rb +27 -0
  35. data/app/controllers/aven/articles_controller.rb +114 -0
  36. data/app/controllers/aven/auth_controller.rb +12 -0
  37. data/app/controllers/aven/chat/threads_controller.rb +67 -0
  38. data/app/controllers/aven/oauth/auth0_controller.rb +84 -0
  39. data/app/controllers/aven/oauth/base_controller.rb +183 -0
  40. data/app/controllers/aven/oauth/documentation/auth0.md +387 -0
  41. data/app/controllers/aven/oauth/documentation/entra_id.md +608 -0
  42. data/app/controllers/aven/oauth/documentation/github.md +329 -0
  43. data/app/controllers/aven/oauth/documentation/google.md +253 -0
  44. data/app/controllers/aven/oauth/entra_id_controller.rb +92 -0
  45. data/app/controllers/aven/oauth/github_controller.rb +91 -0
  46. data/app/controllers/aven/oauth/google_controller.rb +64 -0
  47. data/app/controllers/aven/static_controller.rb +7 -0
  48. data/app/controllers/aven/tags_controller.rb +44 -0
  49. data/app/controllers/aven/workspaces_controller.rb +20 -0
  50. data/app/controllers/concerns/aven/authentication.rb +49 -0
  51. data/app/controllers/concerns/aven/controller_helpers.rb +38 -0
  52. data/app/helpers/aven/application_helper.rb +16 -0
  53. data/app/javascript/aven/application.js +3 -0
  54. data/app/javascript/aven/controllers/application.js +5 -0
  55. data/app/javascript/aven/controllers/index.js +11 -0
  56. data/app/jobs/aven/agentic/document_embedding_job.rb +28 -0
  57. data/app/jobs/aven/agentic/document_ocr_job.rb +28 -0
  58. data/app/jobs/aven/application_job.rb +4 -0
  59. data/app/jobs/aven/chat/calculate_cost_job.rb +26 -0
  60. data/app/jobs/aven/chat/run_job.rb +27 -0
  61. data/app/mailers/aven/application_mailer.rb +6 -0
  62. data/app/models/aven/agentic/agent.rb +76 -0
  63. data/app/models/aven/agentic/agent_document.rb +37 -0
  64. data/app/models/aven/agentic/agent_tool.rb +37 -0
  65. data/app/models/aven/agentic/document.rb +162 -0
  66. data/app/models/aven/agentic/document_embedding.rb +39 -0
  67. data/app/models/aven/agentic/tool.rb +106 -0
  68. data/app/models/aven/agentic/tool_parameter.rb +56 -0
  69. data/app/models/aven/application_record.rb +5 -0
  70. data/app/models/aven/article.rb +86 -0
  71. data/app/models/aven/article_attachment.rb +18 -0
  72. data/app/models/aven/article_relationship.rb +26 -0
  73. data/app/models/aven/chat/message.rb +135 -0
  74. data/app/models/aven/chat/thread.rb +159 -0
  75. data/app/models/aven/import/entry.rb +45 -0
  76. data/app/models/aven/import/item_link.rb +36 -0
  77. data/app/models/aven/import/processor.rb +123 -0
  78. data/app/models/aven/import.rb +102 -0
  79. data/app/models/aven/item/embed.rb +54 -0
  80. data/app/models/aven/item/embeddable.rb +141 -0
  81. data/app/models/aven/item/linkable.rb +212 -0
  82. data/app/models/aven/item/schema/builder.rb +139 -0
  83. data/app/models/aven/item/schemaed.rb +252 -0
  84. data/app/models/aven/item/schemas/base.rb +108 -0
  85. data/app/models/aven/item.rb +128 -0
  86. data/app/models/aven/item_link.rb +43 -0
  87. data/app/models/aven/item_schema.rb +87 -0
  88. data/app/models/aven/log.rb +66 -0
  89. data/app/models/aven/loggable.rb +20 -0
  90. data/app/models/aven/user.rb +40 -0
  91. data/app/models/aven/workspace.rb +93 -0
  92. data/app/models/aven/workspace_role.rb +46 -0
  93. data/app/models/aven/workspace_user.rb +54 -0
  94. data/app/models/aven/workspace_user_role.rb +38 -0
  95. data/app/models/concerns/aven/agentic/document_embeddable.rb +58 -0
  96. data/app/models/concerns/aven/searchable.rb +61 -0
  97. data/app/services/aven/agentic/dynamic_tool_builder.rb +81 -0
  98. data/app/services/aven/agentic/mcp/adapter.rb +77 -0
  99. data/app/services/aven/agentic/mcp/result_formatter.rb +57 -0
  100. data/app/services/aven/agentic/mcp/server_factory.rb +43 -0
  101. data/app/services/aven/agentic/ocr/base_extractor.rb +39 -0
  102. data/app/services/aven/agentic/ocr/excel_extractor.rb +43 -0
  103. data/app/services/aven/agentic/ocr/image_extractor.rb +22 -0
  104. data/app/services/aven/agentic/ocr/pdf_extractor.rb +48 -0
  105. data/app/services/aven/agentic/ocr/processor.rb +36 -0
  106. data/app/services/aven/agentic/ocr/textract_client.rb +131 -0
  107. data/app/services/aven/agentic/ocr/word_extractor.rb +34 -0
  108. data/app/services/aven/agentic/tool_result_formatter.rb +76 -0
  109. data/app/services/aven/agentic/tools/base.rb +55 -0
  110. data/app/services/aven/agentic/tools/concerns/boolean_filtering.rb +40 -0
  111. data/app/services/aven/agentic/tools/concerns/enum_filtering.rb +47 -0
  112. data/app/services/aven/agentic/tools/concerns/geo_filtering.rb +56 -0
  113. data/app/services/aven/agentic/tools/concerns/range_filtering.rb +51 -0
  114. data/app/services/aven/chat/broadcaster.rb +59 -0
  115. data/app/services/aven/chat/config.rb +93 -0
  116. data/app/services/aven/chat/message_builder.rb +42 -0
  117. data/app/services/aven/chat/orchestrator.rb +69 -0
  118. data/app/services/aven/chat/runner.rb +105 -0
  119. data/app/services/aven/chat/title_generator.rb +61 -0
  120. data/app/services/aven/external/gmail_client.rb +173 -0
  121. data/app/services/aven/external/google_contacts_client.rb +95 -0
  122. data/app/views/layouts/aven/admin.html.erb +16 -0
  123. data/app/views/layouts/aven/application.html.erb +18 -0
  124. data/config/importmap.rb +16 -0
  125. data/config/routes.rb +63 -0
  126. data/db/migrate/20200101000001_create_aven_users.rb +19 -0
  127. data/db/migrate/20200101000002_create_aven_workspaces.rb +14 -0
  128. data/db/migrate/20200101000003_create_aven_workspace_users.rb +12 -0
  129. data/db/migrate/20200101000004_create_aven_workspace_roles.rb +13 -0
  130. data/db/migrate/20200101000005_create_aven_workspace_user_roles.rb +12 -0
  131. data/db/migrate/20200101000006_create_aven_logs.rb +21 -0
  132. data/db/migrate/20200101000009_create_aven_items.rb +17 -0
  133. data/db/migrate/20200101000010_create_aven_item_links.rb +17 -0
  134. data/db/migrate/20200101000011_create_aven_agentic_tools.rb +19 -0
  135. data/db/migrate/20200101000012_create_aven_agentic_tool_parameters.rb +20 -0
  136. data/db/migrate/20200101000013_create_aven_agentic_documents.rb +22 -0
  137. data/db/migrate/20200101000014_create_aven_agentic_document_embeddings.rb +18 -0
  138. data/db/migrate/20200101000015_create_aven_agentic_agents.rb +18 -0
  139. data/db/migrate/20200101000016_create_aven_agentic_agent_tools.rb +13 -0
  140. data/db/migrate/20200101000017_create_aven_agentic_agent_documents.rb +13 -0
  141. data/db/migrate/20200101000018_create_aven_chat_threads.rb +19 -0
  142. data/db/migrate/20200101000019_create_aven_chat_messages.rb +26 -0
  143. data/db/migrate/20200101000020_add_pg_search_support.rb +21 -0
  144. data/db/migrate/20200101000021_create_aven_item_schemas.rb +18 -0
  145. data/db/migrate/20200101000022_create_aven_imports.rb +23 -0
  146. data/db/migrate/20200101000023_create_aven_import_entries.rb +13 -0
  147. data/db/migrate/20200101000024_create_aven_import_item_links.rb +13 -0
  148. data/db/migrate/20200101000025_create_aven_articles.rb +19 -0
  149. data/db/migrate/20200101000026_create_aven_article_attachments.rb +13 -0
  150. data/db/migrate/20200101000027_create_aven_article_relationships.rb +15 -0
  151. data/lib/aven/configuration.rb +87 -0
  152. data/lib/aven/engine.rb +43 -0
  153. data/lib/aven/model/tenant_model.rb +91 -0
  154. data/lib/aven/model.rb +6 -0
  155. data/lib/aven/version.rb +3 -0
  156. data/lib/aven.rb +8 -0
  157. data/lib/tasks/annotate_rb.rake +10 -0
  158. data/lib/tasks/aven_tasks.rake +21 -0
  159. metadata +426 -0
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aven
4
+ module Agentic
5
+ class ToolResultFormatter
6
+ MAX_RESULT_LENGTH = 50_000
7
+
8
+ class << self
9
+ # Format tool result for LLM consumption
10
+ # @param tool_name [String] Name of the tool
11
+ # @param result [Object] Raw result from tool execution
12
+ # @return [String] Formatted result string
13
+ def format(tool_name, result)
14
+ formatted = case result
15
+ when Array
16
+ format_array(result)
17
+ when Hash
18
+ format_hash(result)
19
+ when nil
20
+ "No results found."
21
+ else
22
+ result.to_s
23
+ end
24
+
25
+ truncate_if_needed(formatted)
26
+ end
27
+
28
+ private
29
+
30
+ def format_array(results)
31
+ return "No results found." if results.empty?
32
+
33
+ items = results.map.with_index do |item, idx|
34
+ case item
35
+ when Hash
36
+ format_hash_item(item, idx + 1)
37
+ else
38
+ "#{idx + 1}. #{item}"
39
+ end
40
+ end
41
+
42
+ "Found #{results.size} result(s):\n\n#{items.join("\n\n")}"
43
+ end
44
+
45
+ def format_hash(result)
46
+ result.map { |k, v| "#{k}: #{format_value(v)}" }.join("\n")
47
+ end
48
+
49
+ def format_hash_item(item, index)
50
+ lines = item.map { |k, v| " #{k}: #{format_value(v)}" }
51
+ "#{index}.\n#{lines.join("\n")}"
52
+ end
53
+
54
+ def format_value(value)
55
+ case value
56
+ when Array
57
+ value.join(", ")
58
+ when Hash
59
+ value.to_json
60
+ when nil
61
+ "N/A"
62
+ else
63
+ value.to_s
64
+ end
65
+ end
66
+
67
+ def truncate_if_needed(text)
68
+ return text if text.length <= MAX_RESULT_LENGTH
69
+
70
+ truncated = text[0, MAX_RESULT_LENGTH]
71
+ "#{truncated}\n\n[Result truncated - showing first #{MAX_RESULT_LENGTH} characters]"
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aven
4
+ module Agentic
5
+ module Tools
6
+ class Base
7
+ # Parameter definition structure
8
+ Parameter = Struct.new(:name, :type, :description, :required, :constraints, keyword_init: true) do
9
+ def to_h
10
+ super.compact
11
+ end
12
+ end
13
+
14
+ class << self
15
+ # Tool name for registry
16
+ def tool_name
17
+ name.demodulize.underscore
18
+ end
19
+
20
+ # Default description (must be overridden in subclasses)
21
+ def default_description
22
+ raise NotImplementedError, "#{name} must define default_description"
23
+ end
24
+
25
+ # Parameter definitions - override in subclasses
26
+ # @return [Array<Parameter>]
27
+ def parameters
28
+ @parameters ||= []
29
+ end
30
+
31
+ # DSL for defining parameters
32
+ def param(name, type:, desc:, required: false, **constraints)
33
+ parameters << Parameter.new(
34
+ name:,
35
+ type:,
36
+ description: desc,
37
+ required:,
38
+ constraints: constraints.presence
39
+ )
40
+ end
41
+
42
+ # Get parameter by name
43
+ def parameter(name)
44
+ parameters.find { |p| p.name == name.to_sym }
45
+ end
46
+
47
+ # Main entry point - override in subclasses
48
+ def call(**params)
49
+ raise NotImplementedError, "#{name} must define call"
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aven
4
+ module Agentic
5
+ module Tools
6
+ module Concerns
7
+ module BooleanFiltering
8
+ extend ActiveSupport::Concern
9
+
10
+ class_methods do
11
+ # Define boolean filter parameters
12
+ def boolean_filterable(name, column:, desc: nil)
13
+ @boolean_filters ||= {}
14
+ @boolean_filters[name] = { column: }
15
+
16
+ param name, type: :boolean, desc: desc || "Filter by #{name}", required: false
17
+ end
18
+
19
+ def boolean_filters
20
+ @boolean_filters || {}
21
+ end
22
+ end
23
+
24
+ # Apply boolean filtering to a scope
25
+ def apply_boolean_filters(scope, **params)
26
+ self.class.boolean_filters.each do |name, config|
27
+ value = params[name]
28
+ next if value.nil?
29
+
30
+ column = config[:column]
31
+ scope = scope.where(column => value)
32
+ end
33
+
34
+ scope
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aven
4
+ module Agentic
5
+ module Tools
6
+ module Concerns
7
+ module EnumFiltering
8
+ extend ActiveSupport::Concern
9
+
10
+ class_methods do
11
+ # Define enum filter parameters
12
+ def enum_filterable(name, column:, values:, desc: nil)
13
+ @enum_filters ||= {}
14
+ @enum_filters[name] = {
15
+ column:,
16
+ values:
17
+ }
18
+
19
+ param name, type: :string, desc: desc || "Filter by #{name}", required: false
20
+ end
21
+
22
+ def enum_filters
23
+ @enum_filters || {}
24
+ end
25
+ end
26
+
27
+ # Apply enum filtering to a scope
28
+ def apply_enum_filters(scope, **params)
29
+ self.class.enum_filters.each do |name, config|
30
+ value = params[name]
31
+ next if value.blank?
32
+
33
+ column = config[:column]
34
+ allowed = config[:values]
35
+
36
+ if allowed.include?(value)
37
+ scope = scope.where(column => value)
38
+ end
39
+ end
40
+
41
+ scope
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aven
4
+ module Agentic
5
+ module Tools
6
+ module Concerns
7
+ module GeoFiltering
8
+ extend ActiveSupport::Concern
9
+
10
+ class_methods do
11
+ # Define geo search parameters
12
+ def geo_searchable(lat_column: :latitude, lng_column: :longitude)
13
+ @geo_config = {
14
+ lat_column:,
15
+ lng_column:
16
+ }
17
+
18
+ param :latitude, type: :number, desc: "Latitude for geo search"
19
+ param :longitude, type: :number, desc: "Longitude for geo search"
20
+ param :radius_km, type: :number, desc: "Search radius in kilometers", required: false
21
+ end
22
+
23
+ def geo_config
24
+ @geo_config || {}
25
+ end
26
+ end
27
+
28
+ # Apply geo filtering to a scope
29
+ def apply_geo_filter(scope, lat:, lng:, radius_km: 50)
30
+ return scope if lat.blank? || lng.blank?
31
+
32
+ config = self.class.geo_config
33
+ lat_col = config[:lat_column]
34
+ lng_col = config[:lng_column]
35
+
36
+ # Haversine distance formula in SQL
37
+ distance_sql = <<~SQL.squish
38
+ (6371 * acos(
39
+ cos(radians(?)) *
40
+ cos(radians(#{lat_col})) *
41
+ cos(radians(#{lng_col}) - radians(?)) +
42
+ sin(radians(?)) *
43
+ sin(radians(#{lat_col}))
44
+ ))
45
+ SQL
46
+
47
+ scope
48
+ .where("#{lat_col} IS NOT NULL AND #{lng_col} IS NOT NULL")
49
+ .where("#{distance_sql} <= ?", lat, lng, lat, radius_km)
50
+ .order(Arel.sql("#{distance_sql} ASC").gsub("?", lat.to_s).gsub("?", lng.to_s))
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aven
4
+ module Agentic
5
+ module Tools
6
+ module Concerns
7
+ module RangeFiltering
8
+ extend ActiveSupport::Concern
9
+
10
+ class_methods do
11
+ # Define range filter parameters (min/max)
12
+ def range_filterable(name, column:, type: :integer, desc: nil)
13
+ @range_filters ||= {}
14
+ @range_filters[name] = { column: }
15
+
16
+ param_type = type == :integer ? :number : :number
17
+
18
+ param "#{name}_min".to_sym,
19
+ type: param_type,
20
+ desc: desc || "Minimum #{name}",
21
+ required: false
22
+
23
+ param "#{name}_max".to_sym,
24
+ type: param_type,
25
+ desc: desc || "Maximum #{name}",
26
+ required: false
27
+ end
28
+
29
+ def range_filters
30
+ @range_filters || {}
31
+ end
32
+ end
33
+
34
+ # Apply range filtering to a scope
35
+ def apply_range_filters(scope, **params)
36
+ self.class.range_filters.each do |name, config|
37
+ column = config[:column]
38
+ min_val = params["#{name}_min".to_sym]
39
+ max_val = params["#{name}_max".to_sym]
40
+
41
+ scope = scope.where("#{column} >= ?", min_val) if min_val.present?
42
+ scope = scope.where("#{column} <= ?", max_val) if max_val.present?
43
+ end
44
+
45
+ scope
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aven
4
+ module Chat
5
+ class Broadcaster
6
+ def initialize(thread)
7
+ @thread = thread
8
+ end
9
+
10
+ # Broadcast message update
11
+ def broadcast_update(message, **extras)
12
+ broadcast({
13
+ type: "message_updated",
14
+ message: message.as_json.merge(extras)
15
+ })
16
+ end
17
+
18
+ # Broadcast tool call started
19
+ def broadcast_tool_call(message)
20
+ broadcast({
21
+ type: "tool_call",
22
+ message: {
23
+ id: message.id,
24
+ tool_name: message.tool_call&.dig("name"),
25
+ status: "calling"
26
+ }
27
+ })
28
+ end
29
+
30
+ # Broadcast tool result
31
+ def broadcast_tool_result(message)
32
+ broadcast({
33
+ type: "tool_result",
34
+ message: {
35
+ id: message.id,
36
+ tool_call: message.tool_call
37
+ }
38
+ })
39
+ end
40
+
41
+ # Broadcast streaming content
42
+ def broadcast_streaming(message, content)
43
+ broadcast({
44
+ type: "message_streaming",
45
+ message: {
46
+ id: message.id,
47
+ content:
48
+ }
49
+ })
50
+ end
51
+
52
+ private
53
+
54
+ def broadcast(payload)
55
+ Aven::Chat::ThreadChannel.broadcast_to(@thread, payload)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aven
4
+ module Chat
5
+ class Config
6
+ DEFAULT_MODEL = "claude-sonnet-4-5-20250929"
7
+ DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant."
8
+
9
+ class << self
10
+ def model
11
+ Aven.configuration.agentic&.default_model || DEFAULT_MODEL
12
+ end
13
+
14
+ def system_prompt(user: nil, thread: nil)
15
+ parts = [base_system_prompt]
16
+
17
+ # Inject locked documents OCR content into system prompt
18
+ if thread&.documents_locked?
19
+ docs = thread.locked_documents
20
+ if docs.any?
21
+ docs_content = docs.map do |doc|
22
+ "### #{doc.filename}\n\n#{doc.ocr_content}"
23
+ end.join("\n\n---\n\n")
24
+
25
+ parts << <<~TEXT
26
+ ## Reference Documents
27
+
28
+ The following documents are provided as context for this conversation.
29
+ Use them to answer the user's questions.
30
+
31
+ #{docs_content}
32
+ TEXT
33
+ end
34
+ end
35
+
36
+ parts.compact.join("\n\n")
37
+ end
38
+
39
+ # Returns tools available for a thread.
40
+ # - If thread.tools is nil: all tools are available (free-form chat)
41
+ # - If thread.tools is an array: only those tools are available (locked by agent)
42
+ def tools(thread = nil)
43
+ workspace = thread&.workspace
44
+ all_tools = Aven::Agentic::DynamicToolBuilder.build_all(workspace:)
45
+
46
+ return all_tools unless thread&.tools_locked?
47
+
48
+ locked_names = thread.tools
49
+ return [] if locked_names.empty?
50
+
51
+ all_tools.select { |tool| locked_names.include?(tool.tool_name) }
52
+ end
53
+
54
+ # Calculate cost in USD based on token counts.
55
+ def calculate_cost(input_tokens:, output_tokens:, model_id:)
56
+ pricing = pricing_for(model_id)
57
+ return nil unless pricing
58
+
59
+ input_cost = (input_tokens.to_f / 1_000_000) * pricing[:input]
60
+ output_cost = (output_tokens.to_f / 1_000_000) * pricing[:output]
61
+ input_cost + output_cost
62
+ end
63
+
64
+ private
65
+
66
+ def base_system_prompt
67
+ configured = Aven.configuration.agentic&.system_prompt
68
+ return configured.call if configured.respond_to?(:call)
69
+
70
+ configured || DEFAULT_SYSTEM_PROMPT
71
+ end
72
+
73
+ def pricing_for(model_id)
74
+ Rails.cache.fetch("aven/llm_pricing/#{model_id}", expires_in: 24.hours) do
75
+ fetch_pricing(model_id)
76
+ end
77
+ end
78
+
79
+ def fetch_pricing(model_id)
80
+ return nil unless defined?(RubyLLM)
81
+
82
+ model = RubyLLM.models.find(model_id)
83
+ tier = model&.pricing&.text_tokens&.standard
84
+ return nil unless tier
85
+
86
+ { input: tier.input_per_million, output: tier.output_per_million }
87
+ rescue
88
+ nil
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aven
4
+ module Chat
5
+ class MessageBuilder
6
+ def initialize(thread)
7
+ @thread = thread
8
+ end
9
+
10
+ # Build message array for LLM from thread history
11
+ # @return [Array<Hash>] Messages in LLM format
12
+ def build
13
+ @thread.messages
14
+ .chronological
15
+ .where.not(role: :tool) # Tool messages handled separately
16
+ .where(status: :success)
17
+ .map { |msg| format_message(msg) }
18
+ .compact
19
+ end
20
+
21
+ private
22
+
23
+ def format_message(message)
24
+ return nil if message.content.blank? && !message.role_system?
25
+
26
+ {
27
+ role: llm_role(message.role),
28
+ content: message.content || ""
29
+ }
30
+ end
31
+
32
+ def llm_role(role)
33
+ case role.to_sym
34
+ when :user then :user
35
+ when :assistant then :assistant
36
+ when :system then :system
37
+ else :user
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aven
4
+ module Chat
5
+ class Orchestrator
6
+ def initialize(thread)
7
+ @thread = thread
8
+ end
9
+
10
+ # Run chat for a user message
11
+ # @param user_message [Aven::Chat::Message] The user's message
12
+ def run(user_message)
13
+ assistant_message = create_assistant_message(user_message)
14
+ generate_title_if_first_message(user_message)
15
+
16
+ begin
17
+ messages = MessageBuilder.new(@thread).build
18
+ response = Runner.new(@thread, assistant_message).run(messages)
19
+
20
+ assistant_message.mark_completed!(
21
+ content: response.content,
22
+ model: response.model_id,
23
+ tokens: {
24
+ input: response.input_tokens,
25
+ output: response.output_tokens,
26
+ total: response.input_tokens + response.output_tokens
27
+ }
28
+ )
29
+
30
+ # Calculate cost async
31
+ CalculateCostJob.perform_later(assistant_message.id)
32
+ rescue => e
33
+ assistant_message.mark_failed!(e.message)
34
+ raise
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def create_assistant_message(user_message)
41
+ message = @thread.messages.create!(
42
+ role: :assistant,
43
+ parent: user_message,
44
+ status: :pending
45
+ )
46
+ message.mark_started!
47
+ message
48
+ end
49
+
50
+ def generate_title_if_first_message(user_message)
51
+ return unless first_user_message?(user_message)
52
+
53
+ # Generate title in background thread
54
+ ::Thread.new do
55
+ TitleGenerator.new(@thread, user_message).call
56
+ rescue => e
57
+ Rails.logger.error("[Aven::Chat] Title generation error: #{e.message}")
58
+ end
59
+ end
60
+
61
+ def first_user_message?(user_message)
62
+ @thread.messages
63
+ .where(role: :user)
64
+ .where.not(id: user_message.id)
65
+ .none?
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aven
4
+ module Chat
5
+ class Runner
6
+ def initialize(thread, assistant_message)
7
+ @thread = thread
8
+ @assistant_message = assistant_message
9
+ @broadcaster = Broadcaster.new(thread)
10
+ @full_content = ""
11
+ @current_tool_call = nil
12
+ end
13
+
14
+ # Run LLM chat with streaming
15
+ # @param messages [Array<Hash>] Message history
16
+ # @return [OpenStruct] Response with content, model_id, tokens
17
+ def run(messages)
18
+ chat = build_chat
19
+
20
+ # Add conversation history (all messages except the last)
21
+ messages[0..-2].each do |msg|
22
+ chat.add_message(role: msg[:role].to_sym, content: msg[:content])
23
+ end
24
+
25
+ # Ask with the last message
26
+ response = chat.ask(messages.last[:content]) do |chunk|
27
+ handle_stream_chunk(chunk)
28
+ end
29
+
30
+ build_response(response)
31
+ end
32
+
33
+ private
34
+
35
+ def build_chat
36
+ chat = RubyLLM.chat(model: Config.model)
37
+ .with_instructions(Config.system_prompt(thread: @thread))
38
+ .with_tools(*Config.tools(@thread))
39
+
40
+ chat.on_tool_call do |tool_call|
41
+ handle_tool_call(tool_call)
42
+ end
43
+
44
+ chat.on_tool_result do |result|
45
+ handle_tool_result(result)
46
+ end
47
+
48
+ chat
49
+ end
50
+
51
+ def handle_stream_chunk(chunk)
52
+ return unless chunk.content
53
+
54
+ @full_content += chunk.content
55
+ @assistant_message.append_content!(chunk.content)
56
+ end
57
+
58
+ def handle_tool_call(tool_call)
59
+ @current_tool_call = tool_call
60
+
61
+ tool_message = @thread.messages.create!(
62
+ role: :tool,
63
+ parent: @assistant_message.parent,
64
+ status: :streaming,
65
+ content: tool_call.name,
66
+ tool_call: {
67
+ id: tool_call.id,
68
+ name: tool_call.name,
69
+ arguments: tool_call.arguments,
70
+ status: "calling"
71
+ }
72
+ )
73
+
74
+ @broadcaster.broadcast_tool_call(tool_message)
75
+ tool_message
76
+ end
77
+
78
+ def handle_tool_result(result)
79
+ return unless @current_tool_call
80
+
81
+ tool_message = @thread.messages.by_tool_call_id(@current_tool_call.id).first
82
+ return unless tool_message
83
+
84
+ tool_message.update!(
85
+ status: :success,
86
+ tool_call: tool_message.tool_call.merge(
87
+ "result" => result,
88
+ "status" => "completed"
89
+ )
90
+ )
91
+
92
+ @broadcaster.broadcast_tool_result(tool_message)
93
+ end
94
+
95
+ def build_response(response)
96
+ OpenStruct.new(
97
+ content: @full_content,
98
+ model_id: response.model_id,
99
+ input_tokens: response.input_tokens || 0,
100
+ output_tokens: response.output_tokens || 0
101
+ )
102
+ end
103
+ end
104
+ end
105
+ end