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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +35 -0
- data/Rakefile +19 -0
- data/app/assets/stylesheets/aven/application.css +14 -0
- data/app/assets/stylesheets/aven/application.tailwind.css +7 -0
- data/app/assets/stylesheets/aven/tailwind.css +224 -0
- data/app/channels/aven/chat/thread_channel.rb +39 -0
- data/app/components/aven/application_view_component.rb +15 -0
- data/app/components/aven/views/admin/dashboard/index/component.html.erb +1 -0
- data/app/components/aven/views/admin/dashboard/index/component.rb +5 -0
- data/app/components/aven/views/articles/edit/component.html.erb +14 -0
- data/app/components/aven/views/articles/edit/component.rb +14 -0
- data/app/components/aven/views/articles/form/component.html.erb +45 -0
- data/app/components/aven/views/articles/form/component.rb +27 -0
- data/app/components/aven/views/articles/index/component.html.erb +93 -0
- data/app/components/aven/views/articles/index/component.rb +29 -0
- data/app/components/aven/views/articles/new/component.html.erb +13 -0
- data/app/components/aven/views/articles/new/component.rb +14 -0
- data/app/components/aven/views/articles/show/component.html.erb +110 -0
- data/app/components/aven/views/articles/show/component.rb +34 -0
- data/app/components/aven/views/oauth/error/component.html.erb +44 -0
- data/app/components/aven/views/oauth/error/component.rb +30 -0
- data/app/components/aven/views/static/index/component.html.erb +17 -0
- data/app/components/aven/views/static/index/component.rb +16 -0
- data/app/components/aven/views/static/index/controller.js +7 -0
- data/app/controllers/aven/admin/base.rb +16 -0
- data/app/controllers/aven/admin/dashboard_controller.rb +9 -0
- data/app/controllers/aven/agentic/agents_controller.rb +56 -0
- data/app/controllers/aven/agentic/documents_controller.rb +51 -0
- data/app/controllers/aven/agentic/mcp_controller.rb +124 -0
- data/app/controllers/aven/agentic/tools_controller.rb +37 -0
- data/app/controllers/aven/ai/text_controller.rb +41 -0
- data/app/controllers/aven/application_controller.rb +27 -0
- data/app/controllers/aven/articles_controller.rb +114 -0
- data/app/controllers/aven/auth_controller.rb +12 -0
- data/app/controllers/aven/chat/threads_controller.rb +67 -0
- data/app/controllers/aven/oauth/auth0_controller.rb +84 -0
- data/app/controllers/aven/oauth/base_controller.rb +183 -0
- data/app/controllers/aven/oauth/documentation/auth0.md +387 -0
- data/app/controllers/aven/oauth/documentation/entra_id.md +608 -0
- data/app/controllers/aven/oauth/documentation/github.md +329 -0
- data/app/controllers/aven/oauth/documentation/google.md +253 -0
- data/app/controllers/aven/oauth/entra_id_controller.rb +92 -0
- data/app/controllers/aven/oauth/github_controller.rb +91 -0
- data/app/controllers/aven/oauth/google_controller.rb +64 -0
- data/app/controllers/aven/static_controller.rb +7 -0
- data/app/controllers/aven/tags_controller.rb +44 -0
- data/app/controllers/aven/workspaces_controller.rb +20 -0
- data/app/controllers/concerns/aven/authentication.rb +49 -0
- data/app/controllers/concerns/aven/controller_helpers.rb +38 -0
- data/app/helpers/aven/application_helper.rb +16 -0
- data/app/javascript/aven/application.js +3 -0
- data/app/javascript/aven/controllers/application.js +5 -0
- data/app/javascript/aven/controllers/index.js +11 -0
- data/app/jobs/aven/agentic/document_embedding_job.rb +28 -0
- data/app/jobs/aven/agentic/document_ocr_job.rb +28 -0
- data/app/jobs/aven/application_job.rb +4 -0
- data/app/jobs/aven/chat/calculate_cost_job.rb +26 -0
- data/app/jobs/aven/chat/run_job.rb +27 -0
- data/app/mailers/aven/application_mailer.rb +6 -0
- data/app/models/aven/agentic/agent.rb +76 -0
- data/app/models/aven/agentic/agent_document.rb +37 -0
- data/app/models/aven/agentic/agent_tool.rb +37 -0
- data/app/models/aven/agentic/document.rb +162 -0
- data/app/models/aven/agentic/document_embedding.rb +39 -0
- data/app/models/aven/agentic/tool.rb +106 -0
- data/app/models/aven/agentic/tool_parameter.rb +56 -0
- data/app/models/aven/application_record.rb +5 -0
- data/app/models/aven/article.rb +86 -0
- data/app/models/aven/article_attachment.rb +18 -0
- data/app/models/aven/article_relationship.rb +26 -0
- data/app/models/aven/chat/message.rb +135 -0
- data/app/models/aven/chat/thread.rb +159 -0
- data/app/models/aven/import/entry.rb +45 -0
- data/app/models/aven/import/item_link.rb +36 -0
- data/app/models/aven/import/processor.rb +123 -0
- data/app/models/aven/import.rb +102 -0
- data/app/models/aven/item/embed.rb +54 -0
- data/app/models/aven/item/embeddable.rb +141 -0
- data/app/models/aven/item/linkable.rb +212 -0
- data/app/models/aven/item/schema/builder.rb +139 -0
- data/app/models/aven/item/schemaed.rb +252 -0
- data/app/models/aven/item/schemas/base.rb +108 -0
- data/app/models/aven/item.rb +128 -0
- data/app/models/aven/item_link.rb +43 -0
- data/app/models/aven/item_schema.rb +87 -0
- data/app/models/aven/log.rb +66 -0
- data/app/models/aven/loggable.rb +20 -0
- data/app/models/aven/user.rb +40 -0
- data/app/models/aven/workspace.rb +93 -0
- data/app/models/aven/workspace_role.rb +46 -0
- data/app/models/aven/workspace_user.rb +54 -0
- data/app/models/aven/workspace_user_role.rb +38 -0
- data/app/models/concerns/aven/agentic/document_embeddable.rb +58 -0
- data/app/models/concerns/aven/searchable.rb +61 -0
- data/app/services/aven/agentic/dynamic_tool_builder.rb +81 -0
- data/app/services/aven/agentic/mcp/adapter.rb +77 -0
- data/app/services/aven/agentic/mcp/result_formatter.rb +57 -0
- data/app/services/aven/agentic/mcp/server_factory.rb +43 -0
- data/app/services/aven/agentic/ocr/base_extractor.rb +39 -0
- data/app/services/aven/agentic/ocr/excel_extractor.rb +43 -0
- data/app/services/aven/agentic/ocr/image_extractor.rb +22 -0
- data/app/services/aven/agentic/ocr/pdf_extractor.rb +48 -0
- data/app/services/aven/agentic/ocr/processor.rb +36 -0
- data/app/services/aven/agentic/ocr/textract_client.rb +131 -0
- data/app/services/aven/agentic/ocr/word_extractor.rb +34 -0
- data/app/services/aven/agentic/tool_result_formatter.rb +76 -0
- data/app/services/aven/agentic/tools/base.rb +55 -0
- data/app/services/aven/agentic/tools/concerns/boolean_filtering.rb +40 -0
- data/app/services/aven/agentic/tools/concerns/enum_filtering.rb +47 -0
- data/app/services/aven/agentic/tools/concerns/geo_filtering.rb +56 -0
- data/app/services/aven/agentic/tools/concerns/range_filtering.rb +51 -0
- data/app/services/aven/chat/broadcaster.rb +59 -0
- data/app/services/aven/chat/config.rb +93 -0
- data/app/services/aven/chat/message_builder.rb +42 -0
- data/app/services/aven/chat/orchestrator.rb +69 -0
- data/app/services/aven/chat/runner.rb +105 -0
- data/app/services/aven/chat/title_generator.rb +61 -0
- data/app/services/aven/external/gmail_client.rb +173 -0
- data/app/services/aven/external/google_contacts_client.rb +95 -0
- data/app/views/layouts/aven/admin.html.erb +16 -0
- data/app/views/layouts/aven/application.html.erb +18 -0
- data/config/importmap.rb +16 -0
- data/config/routes.rb +63 -0
- data/db/migrate/20200101000001_create_aven_users.rb +19 -0
- data/db/migrate/20200101000002_create_aven_workspaces.rb +14 -0
- data/db/migrate/20200101000003_create_aven_workspace_users.rb +12 -0
- data/db/migrate/20200101000004_create_aven_workspace_roles.rb +13 -0
- data/db/migrate/20200101000005_create_aven_workspace_user_roles.rb +12 -0
- data/db/migrate/20200101000006_create_aven_logs.rb +21 -0
- data/db/migrate/20200101000009_create_aven_items.rb +17 -0
- data/db/migrate/20200101000010_create_aven_item_links.rb +17 -0
- data/db/migrate/20200101000011_create_aven_agentic_tools.rb +19 -0
- data/db/migrate/20200101000012_create_aven_agentic_tool_parameters.rb +20 -0
- data/db/migrate/20200101000013_create_aven_agentic_documents.rb +22 -0
- data/db/migrate/20200101000014_create_aven_agentic_document_embeddings.rb +18 -0
- data/db/migrate/20200101000015_create_aven_agentic_agents.rb +18 -0
- data/db/migrate/20200101000016_create_aven_agentic_agent_tools.rb +13 -0
- data/db/migrate/20200101000017_create_aven_agentic_agent_documents.rb +13 -0
- data/db/migrate/20200101000018_create_aven_chat_threads.rb +19 -0
- data/db/migrate/20200101000019_create_aven_chat_messages.rb +26 -0
- data/db/migrate/20200101000020_add_pg_search_support.rb +21 -0
- data/db/migrate/20200101000021_create_aven_item_schemas.rb +18 -0
- data/db/migrate/20200101000022_create_aven_imports.rb +23 -0
- data/db/migrate/20200101000023_create_aven_import_entries.rb +13 -0
- data/db/migrate/20200101000024_create_aven_import_item_links.rb +13 -0
- data/db/migrate/20200101000025_create_aven_articles.rb +19 -0
- data/db/migrate/20200101000026_create_aven_article_attachments.rb +13 -0
- data/db/migrate/20200101000027_create_aven_article_relationships.rb +15 -0
- data/lib/aven/configuration.rb +87 -0
- data/lib/aven/engine.rb +43 -0
- data/lib/aven/model/tenant_model.rb +91 -0
- data/lib/aven/model.rb +6 -0
- data/lib/aven/version.rb +3 -0
- data/lib/aven.rb +8 -0
- data/lib/tasks/annotate_rb.rake +10 -0
- data/lib/tasks/aven_tasks.rake +21 -0
- 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
|