Merge main into bundle-emails branch
This commit is contained in:
@@ -389,43 +389,15 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card__body {
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card__footer {
|
||||
margin-block: -0.2em 2em;
|
||||
}
|
||||
|
||||
.card__header {
|
||||
margin-block-start: calc(var(--card-padding-block) * -1.1);
|
||||
margin-inline: calc(-1 * var(--card-padding-inline));
|
||||
max-inline-size: unset;
|
||||
}
|
||||
|
||||
.workflow-stage--current {
|
||||
transition: translate 150ms ease-out;
|
||||
|
||||
[open] & {
|
||||
translate: -2em;
|
||||
}
|
||||
}
|
||||
|
||||
.card__link {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card__meta-text--updated {
|
||||
border: 0;
|
||||
font-size: var(--text-x-small);
|
||||
opacity: 0.66;
|
||||
padding: 0;
|
||||
text-transform: none;
|
||||
|
||||
.local-time-value {
|
||||
font-weight: normal;
|
||||
}
|
||||
.card__body {
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.workflow-stage--current {
|
||||
@@ -438,6 +410,40 @@
|
||||
flex: 0 1 auto;
|
||||
inline-size: fit-content;
|
||||
margin: 0 0 0 auto;
|
||||
transition: translate 150ms ease-out;
|
||||
|
||||
[open] & {
|
||||
translate: -2em;
|
||||
}
|
||||
}
|
||||
|
||||
.card__link {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card__footer {
|
||||
margin-block: -0.2em 2em;
|
||||
}
|
||||
|
||||
.card__meta {
|
||||
grid-template-areas: "text-updated";
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card__meta-text {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card__meta-text--updated {
|
||||
border: 0;
|
||||
font-size: var(--text-x-small);
|
||||
opacity: 0.66;
|
||||
padding: 0;
|
||||
text-transform: none;
|
||||
|
||||
.local-time-value {
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.card__bubble {
|
||||
|
||||
@@ -43,4 +43,17 @@ module FiltersHelper
|
||||
concat tag.kbd(key)
|
||||
end
|
||||
end
|
||||
|
||||
def sorted_by_label(sort_value)
|
||||
case sort_value
|
||||
when "newest"
|
||||
"Newest to oldest"
|
||||
when "oldest"
|
||||
"Oldest to newest"
|
||||
when "latest"
|
||||
"Recently updated"
|
||||
else
|
||||
sort_value.humanize
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
class Ai::UsageCost
|
||||
attr_reader :model_id, :input_tokens, :output_tokens
|
||||
|
||||
class << self
|
||||
def from_llm_response(response)
|
||||
new \
|
||||
model_id: response.model_id,
|
||||
input_tokens: response.input_tokens,
|
||||
output_tokens: response.output_tokens
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(model_id:, input_tokens:, output_tokens:)
|
||||
@model_id = model_id
|
||||
@input_tokens = input_tokens
|
||||
@output_tokens = output_tokens
|
||||
end
|
||||
|
||||
def in_microcents
|
||||
input_cost_in_microcents + output_cost_in_microcents
|
||||
end
|
||||
|
||||
def input_cost_in_microcents
|
||||
calculate_token_cost(input_tokens, model_info.input_price_per_million)
|
||||
end
|
||||
|
||||
def output_cost_in_microcents
|
||||
calculate_token_cost(output_tokens, model_info.output_price_per_million)
|
||||
end
|
||||
|
||||
private
|
||||
def calculate_token_cost(token_count, price_per_million)
|
||||
return 0 unless price_per_million
|
||||
|
||||
single_token_price = price_per_million.to_d / 1_000_000
|
||||
token_cost_dollars = token_count * single_token_price
|
||||
Ai::Quota::Money.wrap(token_cost_dollars).in_microcents
|
||||
end
|
||||
|
||||
def model_info
|
||||
@model_info ||= RubyLLM.models.find(model_id)
|
||||
end
|
||||
end
|
||||
@@ -16,9 +16,9 @@ module Conversation::Message::Respondable
|
||||
model_id: response.model_id,
|
||||
input_tokens: response.input_tokens,
|
||||
output_tokens: response.output_tokens,
|
||||
input_cost_in_microcents: response.input_cost_in_microcents,
|
||||
output_cost_in_microcents: response.output_cost_in_microcents,
|
||||
cost_in_microcents: response.cost_in_microcents
|
||||
input_cost_in_microcents: response.cost.input_cost_in_microcents,
|
||||
output_cost_in_microcents: response.cost.output_cost_in_microcents,
|
||||
cost_in_microcents: response.cost.in_microcents
|
||||
}
|
||||
|
||||
conversation.respond(response.answer, **message_attributes)
|
||||
|
||||
@@ -8,41 +8,7 @@ class Conversation::Message::ResponseGenerator::Response
|
||||
@model_id = model_id
|
||||
end
|
||||
|
||||
def cost_in_microcents
|
||||
input_cost_in_microcents + output_cost_in_microcents
|
||||
def cost
|
||||
@cost ||= Ai::UsageCost.from_llm_response(self)
|
||||
end
|
||||
|
||||
def input_cost_in_microcents
|
||||
return unless token_price = input_token_price_microcents
|
||||
|
||||
(input_tokens * token_price).to_i
|
||||
end
|
||||
|
||||
def input_token_price_microcents
|
||||
return unless model_info
|
||||
|
||||
price_per_million_tokens_in_microcents(model_info.input_price_per_million)
|
||||
end
|
||||
|
||||
def output_cost_in_microcents
|
||||
return unless token_price = output_token_price_microcents
|
||||
|
||||
(output_tokens * token_price).to_i
|
||||
end
|
||||
|
||||
def output_token_price_microcents
|
||||
return unless model_info
|
||||
|
||||
price_per_million_tokens_in_microcents(model_info.output_price_per_million)
|
||||
end
|
||||
|
||||
def model_info
|
||||
@model_info ||= RubyLLM.models.find(model_id)
|
||||
end
|
||||
|
||||
private
|
||||
def price_per_million_tokens_in_microcents(price)
|
||||
single_token_price = price.to_d / 1_000_000
|
||||
Ai::Quota::Money.wrap(single_token_price).in_microcents
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,11 +8,11 @@ class Event::ActivitySummary < ApplicationRecord
|
||||
key = key_for(events)
|
||||
|
||||
# Outside to avoid holding the transaction during the LLM request
|
||||
summary, cost_in_microcents = Event::Summarizer.new(events).summarize
|
||||
summarizer = Event::Summarizer.new(events)
|
||||
|
||||
create_or_find_by!(key: key) do |record|
|
||||
record.content = summary
|
||||
record.cost_in_microcents = cost_in_microcents
|
||||
record.content = summarizer.summarized_content
|
||||
record.cost_in_microcents = summarizer.cost.in_microcents
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -27,9 +27,12 @@ class Event::Summarizer
|
||||
@llm_model = llm_model
|
||||
end
|
||||
|
||||
def summarize
|
||||
response = chat.ask join_prompts("Summarize the following content:", summarizable_content)
|
||||
[ response.content, calculate_cost_in_microcents(response) ]
|
||||
def summarized_content
|
||||
llm_response.content
|
||||
end
|
||||
|
||||
def cost
|
||||
Ai::UsageCost.from_llm_response(llm_response)
|
||||
end
|
||||
|
||||
def summarizable_content
|
||||
@@ -39,25 +42,12 @@ class Event::Summarizer
|
||||
private
|
||||
attr_reader :prompt, :llm_model
|
||||
|
||||
def llm_response
|
||||
@llm_response ||= chat.ask join_prompts("Summarize the following content:", summarizable_content)
|
||||
end
|
||||
|
||||
def chat
|
||||
chat = RubyLLM.chat(model: llm_model)
|
||||
chat.with_instructions(join_prompts(prompt, domain_model_prompt, user_data_injection_prompt))
|
||||
end
|
||||
|
||||
def calculate_cost_in_microcents(response)
|
||||
model_info = RubyLLM.models.find(response.model_id)
|
||||
|
||||
input_cost = calculate_token_cost(response.input_tokens, model_info.input_price_per_million)
|
||||
output_cost = calculate_token_cost(response.output_tokens, model_info.output_price_per_million)
|
||||
|
||||
input_cost + output_cost
|
||||
end
|
||||
|
||||
def calculate_token_cost(token_count, price_per_million)
|
||||
return 0 unless price_per_million
|
||||
|
||||
single_token_price = price_per_million.to_d / 1_000_000
|
||||
token_cost_dollars = token_count * single_token_price
|
||||
Ai::Quota::Money.wrap(token_cost_dollars).in_microcents
|
||||
end
|
||||
end
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
class User < ApplicationRecord
|
||||
include Accessor, Attachable, Assignee, Configurable, Mentionable, Named, Notifiable, Role,
|
||||
Searcher, SignalUser, Staff, Transferable, Conversational, AiQuota
|
||||
include Accessor, AiQuota, Assignee, Attachable, Configurable, Conversational, Mentionable, Named,
|
||||
Notifiable, Role, Searcher, SignalUser, Staff, Transferable
|
||||
include Timelined # Depends on Accessor
|
||||
|
||||
has_one_attached :avatar
|
||||
|
||||
@@ -56,6 +56,7 @@ class User::Filtering
|
||||
end
|
||||
|
||||
def show_tags?
|
||||
return unless Tag.any?
|
||||
expanded? || filter.tags.any?
|
||||
end
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="settings__panel panel shadow center">
|
||||
<h2 class="divider txt-large">Do or Die</h2>
|
||||
<h2 class="divider txt-large">Sink or Swim</h2>
|
||||
<p class="margin-none-block-start">Choose default settings for this account. You can override them in individual collections.</p>
|
||||
<%= render "entropy/auto_close", model: account.default_entropy_configuration, url: account_entropy_configuration_path %>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<%= form.hidden_field :engagement, value: "on_deck" %>
|
||||
<%= form.label :engagement, value: "on_deck", class: "btn", aria: { label: "Move to On Deck"} do %>
|
||||
<%= form.radio_button :engagement, "on_deck", checked: @card.on_deck?, class: "for-screen-reader", data: { action: "change->form#submit" } %>
|
||||
<span>Maybe?</span>
|
||||
<span>On Deck</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<%= form.hidden_field :engagement, value: "doing" %>
|
||||
<%= form.label :engagement, value: "doing", class: "btn", aria: { label: "Move to Doing"} do %>
|
||||
<%= form.radio_button :engagement, "doing", checked: @card.doing?, class: "for-screen-reader", data: { action: "change->form#submit" } %>
|
||||
<span style="min-inline-size: 8ch">Yes!</span>
|
||||
<span style="min-inline-size: 8ch">Working On</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<div class="cards__heading" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
|
||||
<button class="cards__filter btn input input--select borderless overflow-ellipsis" data-action="click->dialog#open:stop">
|
||||
<% if filter.stages.any? %>
|
||||
Yes!: <%= filter.stages.map(&:name).to_sentence %>
|
||||
Working On: <%= filter.stages.map(&:name).to_sentence %>
|
||||
<% else %>
|
||||
Yes!
|
||||
Working On
|
||||
<% end %>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<%= render "cards/index/workflow_filter", workflow: workflow, filter: filter %>
|
||||
<% else %>
|
||||
<h2 class="cards__heading">
|
||||
Yes!
|
||||
Working On
|
||||
</h2>
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<section id="on-deck-cards" class="cards cards--on-deck" data-drag-and-drop-target="container" data-drop-target="on_deck">
|
||||
<h2 class="cards__heading">Maybe?</h2>
|
||||
<h2 class="cards__heading">On Deck</h2>
|
||||
|
||||
<% if page.used? %>
|
||||
<%= render partial: "cards/display/preview", collection: page.records, as: :card, locals: { draggable: true }, cached: true %>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<%= turbo_frame_tag @collection, :entropy_configuration do %>
|
||||
<h2 class="divider txt-large">Do or Die</h2>
|
||||
<h2 class="divider txt-large">Sink or Swim</h2>
|
||||
<%= render "entropy/auto_close", model: collection, url: collection_entropy_configuration_path(collection) %>
|
||||
<% end %>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="separator margin-block-half" style="--border-color: var(--color-ink-light); min-block-size: 3lh;"></div>
|
||||
|
||||
<div class="flex flex-column full-width" style="flex-basis: 48%">
|
||||
<strong>Cards in Maybe?/Yes!</strong>
|
||||
<strong>Cards in On Deck/Working On</strong>
|
||||
<p class="txt-x-small margin-none-block-start">Back to The Stream after…</p>
|
||||
<%= form_with model: model, url: url, data: { controller: "form" } do |form| %>
|
||||
<%= render "entropy/knob",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="quick-filter position-relative <%= "default-value" if filter.sorted_by.latest? %>" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
|
||||
<button class="btn input input--select flex-inline txt-x-small" data-action="click->dialog#open:stop">
|
||||
<span class="overflow-ellipsis">
|
||||
<%= filter.sorted_by.humanize %>
|
||||
<%= sorted_by_label(filter.sorted_by) %>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<% end %>
|
||||
|
||||
<%= form.button type: "submit", class: "popup__item btn" do %>
|
||||
<span class="overflow-ellipsis flex-item-grow"><%= sort.humanize %></span>
|
||||
<span class="overflow-ellipsis flex-item-grow"><%= sorted_by_label(sort) %></span>
|
||||
<% if filter.sorted_by == sort %>
|
||||
<%= icon_tag "check", class: "checked flex-item-justify-end" %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<section id="doing-cards" class="cards cards--doing" style="view-transition-name: cards-container;">
|
||||
<h2 class="cards__heading">
|
||||
Yes!
|
||||
Working On
|
||||
</h2>
|
||||
|
||||
<% if page.used? %>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<section id="considering-cards" class="cards cards--on-deck">
|
||||
<h2 class="cards__heading">
|
||||
Maybe?
|
||||
On Deck
|
||||
</h2>
|
||||
|
||||
<% if page.used? %>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
require "test_helper"
|
||||
|
||||
class Ai::ResponseCostTest < ActiveSupport::TestCase
|
||||
test "price calculations" do
|
||||
response_cost = Ai::UsageCost.new(
|
||||
model_id: "gpt-4",
|
||||
input_tokens: 198,
|
||||
output_tokens: 2
|
||||
)
|
||||
|
||||
# We've got 198 input tokens, so that's
|
||||
# 198 * 3000 = 594000
|
||||
assert_equal 594000, response_cost.input_cost_in_microcents
|
||||
|
||||
# We've got 2 output tokens, so that's
|
||||
# 2 * 6000 = 12000
|
||||
assert_equal 12000, response_cost.output_cost_in_microcents
|
||||
|
||||
# So the total is 594000 + 12000 micro-cents
|
||||
assert_equal 606000, response_cost.in_microcents
|
||||
end
|
||||
end
|
||||
@@ -1,32 +0,0 @@
|
||||
require "test_helper"
|
||||
|
||||
class Conversation::Message::ResponseGenerator::ResponseTest < ActiveSupport::TestCase
|
||||
test "price calculations" do
|
||||
response = Conversation::Message::ResponseGenerator::Response.new(
|
||||
answer: "Hi!",
|
||||
input_tokens: 198,
|
||||
output_tokens: 2,
|
||||
model_id: "gpt-4"
|
||||
)
|
||||
|
||||
# The price of an input token is 30 USD per million tokens
|
||||
# and 60 USD per million output tokens
|
||||
# That's 0.00003 cents per input token and 0.00006 cents
|
||||
# per output token
|
||||
# Which is 3000 micro-cents per input token and 6000 micro-cents
|
||||
# per output token
|
||||
assert_equal "3000.0".to_d, response.input_token_price_microcents
|
||||
assert_equal "6000.0".to_d, response.output_token_price_microcents
|
||||
|
||||
# We've got 198 input tokens, so that's
|
||||
# 198 * 3000 = 594000
|
||||
assert_equal 594000, response.input_cost_in_microcents
|
||||
|
||||
# We've got 2 output tokens, so that's
|
||||
# 2 * 6000 = 12
|
||||
assert_equal 12000, response.output_cost_in_microcents
|
||||
|
||||
# So the total is 594000 + 12000 micro-cents
|
||||
assert_equal 606000, response.cost_in_microcents
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user