feat: start on AI integration
This commit is contained in:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"mix": true,
|
||||
"allium": true
|
||||
"allium": true,
|
||||
"command": true
|
||||
}
|
||||
}
|
||||
1314
lib/bds/ai.ex
Normal file
1314
lib/bds/ai.ex
Normal file
File diff suppressed because it is too large
Load Diff
18
lib/bds/ai/catalog_meta.ex
Normal file
18
lib/bds/ai/catalog_meta.ex
Normal file
@@ -0,0 +1,18 @@
|
||||
defmodule BDS.AI.CatalogMeta do
|
||||
@moduledoc false
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:key, :string, autogenerate: false}
|
||||
|
||||
schema "ai_catalog_meta" do
|
||||
field :value, :string
|
||||
end
|
||||
|
||||
def changeset(meta, attrs) do
|
||||
meta
|
||||
|> cast(attrs, [:key, :value], empty_values: [nil])
|
||||
|> validate_required([:key, :value])
|
||||
end
|
||||
end
|
||||
23
lib/bds/ai/catalog_provider.ex
Normal file
23
lib/bds/ai/catalog_provider.ex
Normal file
@@ -0,0 +1,23 @@
|
||||
defmodule BDS.AI.CatalogProvider do
|
||||
@moduledoc false
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :string, autogenerate: false}
|
||||
|
||||
schema "ai_providers" do
|
||||
field :name, :string
|
||||
field :env_keys, :string, source: :env
|
||||
field :package_ref, :string
|
||||
field :api_url, :string, source: :api
|
||||
field :doc_url, :string, source: :doc
|
||||
field :updated_at, :integer
|
||||
end
|
||||
|
||||
def changeset(provider, attrs) do
|
||||
provider
|
||||
|> cast(attrs, [:id, :name, :env_keys, :package_ref, :api_url, :doc_url, :updated_at], empty_values: [nil])
|
||||
|> validate_required([:id, :name, :updated_at])
|
||||
end
|
||||
end
|
||||
22
lib/bds/ai/chat_conversation.ex
Normal file
22
lib/bds/ai/chat_conversation.ex
Normal file
@@ -0,0 +1,22 @@
|
||||
defmodule BDS.AI.ChatConversation do
|
||||
@moduledoc false
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :string, autogenerate: false}
|
||||
|
||||
schema "chat_conversations" do
|
||||
field :title, :string
|
||||
field :model, :string
|
||||
field :copilot_session_id, :string
|
||||
field :created_at, :integer
|
||||
field :updated_at, :integer
|
||||
end
|
||||
|
||||
def changeset(conversation, attrs) do
|
||||
conversation
|
||||
|> cast(attrs, [:id, :title, :model, :copilot_session_id, :created_at, :updated_at], empty_values: [nil])
|
||||
|> validate_required([:id, :title, :created_at, :updated_at])
|
||||
end
|
||||
end
|
||||
41
lib/bds/ai/chat_message.ex
Normal file
41
lib/bds/ai/chat_message.ex
Normal file
@@ -0,0 +1,41 @@
|
||||
defmodule BDS.AI.ChatMessage do
|
||||
@moduledoc false
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@foreign_key_type :string
|
||||
@roles [:system, :user, :assistant, :tool]
|
||||
|
||||
schema "chat_messages" do
|
||||
belongs_to :conversation, BDS.AI.ChatConversation, references: :id, type: :string
|
||||
|
||||
field :role, Ecto.Enum, values: @roles
|
||||
field :content, :string
|
||||
field :tool_call_id, :string
|
||||
field :tool_calls, :string
|
||||
field :token_usage_input, :integer
|
||||
field :token_usage_output, :integer
|
||||
field :cache_read_tokens, :integer
|
||||
field :cache_write_tokens, :integer
|
||||
field :created_at, :integer
|
||||
end
|
||||
|
||||
def changeset(message, attrs) do
|
||||
message
|
||||
|> cast(attrs, [
|
||||
:conversation_id,
|
||||
:role,
|
||||
:content,
|
||||
:tool_call_id,
|
||||
:tool_calls,
|
||||
:token_usage_input,
|
||||
:token_usage_output,
|
||||
:cache_read_tokens,
|
||||
:cache_write_tokens,
|
||||
:created_at
|
||||
], empty_values: [nil])
|
||||
|> validate_required([:conversation_id, :role, :created_at])
|
||||
|> assoc_constraint(:conversation)
|
||||
end
|
||||
end
|
||||
51
lib/bds/ai/http_client.ex
Normal file
51
lib/bds/ai/http_client.ex
Normal file
@@ -0,0 +1,51 @@
|
||||
defmodule BDS.AI.HttpClient do
|
||||
@moduledoc false
|
||||
|
||||
def get(url, headers) when is_binary(url) and is_map(headers) do
|
||||
request = {String.to_charlist(url), Enum.map(headers, fn {key, value} -> {String.to_charlist(key), String.to_charlist(value)} end)}
|
||||
|
||||
:inets.start()
|
||||
:ssl.start()
|
||||
|
||||
case :httpc.request(:get, request, [], body_format: :binary) do
|
||||
{:ok, {{_version, status, _reason}, response_headers, body}} ->
|
||||
{:ok,
|
||||
%{
|
||||
status: status,
|
||||
headers: normalize_headers(response_headers),
|
||||
body: body
|
||||
}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
def post(url, headers, body)
|
||||
when is_binary(url) and is_map(headers) and is_binary(body) do
|
||||
request =
|
||||
{String.to_charlist(url), Enum.map(headers, fn {key, value} -> {String.to_charlist(key), String.to_charlist(value)} end), ~c"application/json", body}
|
||||
|
||||
:inets.start()
|
||||
:ssl.start()
|
||||
|
||||
case :httpc.request(:post, request, [], body_format: :binary) do
|
||||
{:ok, {{_version, status, _reason}, response_headers, response_body}} ->
|
||||
{:ok,
|
||||
%{
|
||||
status: status,
|
||||
headers: normalize_headers(response_headers),
|
||||
body: response_body
|
||||
}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_headers(headers) do
|
||||
Enum.into(headers, %{}, fn {key, value} ->
|
||||
{key |> to_string() |> String.downcase(), to_string(value)}
|
||||
end)
|
||||
end
|
||||
end
|
||||
29
lib/bds/ai/in_flight.ex
Normal file
29
lib/bds/ai/in_flight.ex
Normal file
@@ -0,0 +1,29 @@
|
||||
defmodule BDS.AI.InFlight do
|
||||
@moduledoc false
|
||||
|
||||
@table :bds_ai_in_flight
|
||||
|
||||
def register(conversation_id, pid) when is_binary(conversation_id) and is_pid(pid) do
|
||||
:ets.insert(table(), {conversation_id, pid})
|
||||
:ok
|
||||
end
|
||||
|
||||
def unregister(conversation_id) when is_binary(conversation_id) do
|
||||
:ets.delete(table(), conversation_id)
|
||||
:ok
|
||||
end
|
||||
|
||||
def lookup(conversation_id) when is_binary(conversation_id) do
|
||||
case :ets.lookup(table(), conversation_id) do
|
||||
[{^conversation_id, pid}] -> pid
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp table do
|
||||
case :ets.whereis(@table) do
|
||||
:undefined -> :ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
|
||||
table -> table
|
||||
end
|
||||
end
|
||||
end
|
||||
64
lib/bds/ai/model.ex
Normal file
64
lib/bds/ai/model.ex
Normal file
@@ -0,0 +1,64 @@
|
||||
defmodule BDS.AI.Model do
|
||||
@moduledoc false
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key false
|
||||
|
||||
schema "ai_models" do
|
||||
field :provider, :string, primary_key: true
|
||||
field :model_id, :string, primary_key: true
|
||||
field :name, :string
|
||||
field :family, :string
|
||||
field :supports_attachment, :boolean, source: :attachment, default: false
|
||||
field :supports_reasoning, :boolean, source: :reasoning, default: false
|
||||
field :supports_tool_calls, :boolean, source: :tool_call, default: false
|
||||
field :supports_structured_output, :boolean, source: :structured_output, default: false
|
||||
field :supports_temperature, :boolean, source: :temperature, default: false
|
||||
field :knowledge, :string
|
||||
field :release_date, :string
|
||||
field :last_updated_date, :string
|
||||
field :open_weights, :boolean, default: false
|
||||
field :input_price, :integer
|
||||
field :output_price, :integer
|
||||
field :cache_read_price, :integer
|
||||
field :cache_write_price, :integer
|
||||
field :context_window, :integer
|
||||
field :max_input_tokens, :integer
|
||||
field :max_output_tokens, :integer
|
||||
field :interleaved, :string
|
||||
field :status, :string
|
||||
field :updated_at, :integer
|
||||
end
|
||||
|
||||
def changeset(model, attrs) do
|
||||
model
|
||||
|> cast(attrs, [
|
||||
:provider,
|
||||
:model_id,
|
||||
:name,
|
||||
:family,
|
||||
:supports_attachment,
|
||||
:supports_reasoning,
|
||||
:supports_tool_calls,
|
||||
:supports_structured_output,
|
||||
:supports_temperature,
|
||||
:knowledge,
|
||||
:release_date,
|
||||
:last_updated_date,
|
||||
:open_weights,
|
||||
:input_price,
|
||||
:output_price,
|
||||
:cache_read_price,
|
||||
:cache_write_price,
|
||||
:context_window,
|
||||
:max_input_tokens,
|
||||
:max_output_tokens,
|
||||
:interleaved,
|
||||
:status,
|
||||
:updated_at
|
||||
], empty_values: [nil])
|
||||
|> validate_required([:provider, :model_id, :name, :context_window, :max_input_tokens, :max_output_tokens, :updated_at])
|
||||
end
|
||||
end
|
||||
21
lib/bds/ai/model_modality.ex
Normal file
21
lib/bds/ai/model_modality.ex
Normal file
@@ -0,0 +1,21 @@
|
||||
defmodule BDS.AI.ModelModality do
|
||||
@moduledoc false
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key false
|
||||
|
||||
schema "ai_model_modalities" do
|
||||
field :provider, :string, primary_key: true
|
||||
field :model_id, :string, primary_key: true
|
||||
field :direction, Ecto.Enum, values: [:input, :output], primary_key: true
|
||||
field :modality, Ecto.Enum, values: [:text, :image, :audio, :file, :tool], primary_key: true
|
||||
end
|
||||
|
||||
def changeset(modality, attrs) do
|
||||
modality
|
||||
|> cast(attrs, [:provider, :model_id, :direction, :modality], empty_values: [nil])
|
||||
|> validate_required([:provider, :model_id, :direction, :modality])
|
||||
end
|
||||
end
|
||||
108
lib/bds/ai/openai_compatible_runtime.ex
Normal file
108
lib/bds/ai/openai_compatible_runtime.ex
Normal file
@@ -0,0 +1,108 @@
|
||||
defmodule BDS.AI.OpenAICompatibleRuntime do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.AI.HttpClient
|
||||
|
||||
def generate(endpoint, request, _opts) when is_map(endpoint) and is_map(request) do
|
||||
url = completions_url(endpoint.url)
|
||||
|
||||
headers =
|
||||
%{
|
||||
"content-type" => "application/json",
|
||||
"accept" => "application/json"
|
||||
}
|
||||
|> maybe_put_auth(endpoint.api_key)
|
||||
|
||||
payload = %{
|
||||
"model" => request.model,
|
||||
"messages" => request.messages,
|
||||
"max_tokens" => request.max_output_tokens
|
||||
}
|
||||
|> maybe_put_tools(request.tools)
|
||||
|
||||
with {:ok, response} <- HttpClient.post(url, headers, Jason.encode!(payload)),
|
||||
200 <- response.status do
|
||||
normalize_response(response.body)
|
||||
else
|
||||
status when is_integer(status) -> {:error, %{kind: :http_error, status: status}}
|
||||
{:error, reason} -> {:error, %{kind: :http_error, reason: reason}}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_response(body) do
|
||||
payload = Jason.decode!(body)
|
||||
message = get_in(payload, ["choices", Access.at(0), "message"]) || %{}
|
||||
content = normalize_content(message["content"])
|
||||
tool_calls = normalize_tool_calls(message["tool_calls"] || [])
|
||||
usage = normalize_usage(payload["usage"] || %{})
|
||||
|
||||
json =
|
||||
case content do
|
||||
nil -> nil
|
||||
value when is_binary(value) ->
|
||||
case Jason.decode(value) do
|
||||
{:ok, decoded} when is_map(decoded) -> decoded
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, %{content: content, json: json, tool_calls: tool_calls, usage: usage}}
|
||||
end
|
||||
|
||||
defp completions_url(url) do
|
||||
cond do
|
||||
String.ends_with?(url, "/chat/completions") -> url
|
||||
String.ends_with?(url, "/") -> url <> "chat/completions"
|
||||
true -> url <> "/chat/completions"
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put_auth(headers, nil), do: headers
|
||||
defp maybe_put_auth(headers, ""), do: headers
|
||||
defp maybe_put_auth(headers, api_key), do: Map.put(headers, "authorization", "Bearer #{api_key}")
|
||||
|
||||
defp maybe_put_tools(payload, []), do: payload
|
||||
defp maybe_put_tools(payload, nil), do: payload
|
||||
|
||||
defp maybe_put_tools(payload, tools) do
|
||||
Map.put(payload, "tools", tools)
|
||||
|> Map.put("tool_choice", "auto")
|
||||
end
|
||||
|
||||
defp normalize_tool_calls(tool_calls) do
|
||||
Enum.map(tool_calls, fn tool_call ->
|
||||
%{
|
||||
id: tool_call["id"],
|
||||
name: get_in(tool_call, ["function", "name"]),
|
||||
arguments: decode_arguments(get_in(tool_call, ["function", "arguments"]))
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp decode_arguments(nil), do: %{}
|
||||
|
||||
defp decode_arguments(arguments) when is_binary(arguments) do
|
||||
case Jason.decode(arguments) do
|
||||
{:ok, decoded} when is_map(decoded) -> decoded
|
||||
_other -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_content(nil), do: nil
|
||||
defp normalize_content(content) when is_binary(content), do: content
|
||||
|
||||
defp normalize_content(content) when is_list(content) do
|
||||
content
|
||||
|> Enum.map(fn item -> item["text"] || "" end)
|
||||
|> Enum.join()
|
||||
end
|
||||
|
||||
defp normalize_usage(usage) do
|
||||
%{
|
||||
input_tokens: usage["prompt_tokens"],
|
||||
output_tokens: usage["completion_tokens"],
|
||||
cache_read_tokens: get_in(usage, ["prompt_tokens_details", "cached_tokens"]),
|
||||
cache_write_tokens: get_in(usage, ["completion_tokens_details", "cached_tokens"])
|
||||
}
|
||||
end
|
||||
end
|
||||
33
lib/bds/ai/secret_backend.ex
Normal file
33
lib/bds/ai/secret_backend.ex
Normal file
@@ -0,0 +1,33 @@
|
||||
defmodule BDS.AI.SecretBackend do
|
||||
@moduledoc false
|
||||
|
||||
@aad "bds-ai-secret"
|
||||
|
||||
def encrypt(value) when is_binary(value) do
|
||||
key = secret_key()
|
||||
iv = :crypto.strong_rand_bytes(12)
|
||||
|
||||
{ciphertext, tag} =
|
||||
:crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, value, @aad, true)
|
||||
|
||||
{:ok, Base.encode64(iv <> tag <> ciphertext)}
|
||||
end
|
||||
|
||||
def decrypt(encoded) when is_binary(encoded) do
|
||||
with {:ok, binary} <- Base.decode64(encoded),
|
||||
<<iv::binary-size(12), tag::binary-size(16), ciphertext::binary>> <- binary,
|
||||
plaintext when is_binary(plaintext) <-
|
||||
:crypto.crypto_one_time_aead(:aes_256_gcm, secret_key(), iv, ciphertext, @aad, tag, false) do
|
||||
{:ok, plaintext}
|
||||
else
|
||||
_other -> {:error, :invalid_ciphertext}
|
||||
end
|
||||
end
|
||||
|
||||
defp secret_key do
|
||||
case Application.get_env(:bds, :ai_secret_key) do
|
||||
key when is_binary(key) and byte_size(key) >= 32 -> binary_part(key, 0, 32)
|
||||
_other -> :crypto.hash(:sha256, Atom.to_string(node()) <> ":bds:ai")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
defmodule BDS.Repo.Migrations.AddAiChatUsageFields do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:chat_messages) do
|
||||
add :token_usage_input, :integer
|
||||
add :token_usage_output, :integer
|
||||
add :cache_read_tokens, :integer
|
||||
add :cache_write_tokens, :integer
|
||||
end
|
||||
end
|
||||
end
|
||||
216
specs/ai.allium
216
specs/ai.allium
@@ -18,6 +18,61 @@ entity AiEndpoint {
|
||||
-- airplane: local model (Ollama, LM Studio, etc.)
|
||||
}
|
||||
|
||||
entity AiCatalogProvider {
|
||||
id: String
|
||||
name: String
|
||||
env_keys: Set<String>
|
||||
package_ref: String?
|
||||
api_url: String?
|
||||
doc_url: String?
|
||||
updated_at: Timestamp
|
||||
}
|
||||
|
||||
entity AiModel {
|
||||
provider: AiCatalogProvider
|
||||
model_id: String
|
||||
name: String
|
||||
family: String?
|
||||
supports_attachment: Boolean
|
||||
supports_reasoning: Boolean
|
||||
supports_tool_calls: Boolean
|
||||
supports_structured_output: Boolean
|
||||
supports_temperature: Boolean
|
||||
knowledge: String?
|
||||
release_date: String?
|
||||
last_updated_date: String?
|
||||
open_weights: Boolean
|
||||
input_price: Integer?
|
||||
output_price: Integer?
|
||||
cache_read_price: Integer?
|
||||
cache_write_price: Integer?
|
||||
context_window: Integer
|
||||
max_input_tokens: Integer
|
||||
max_output_tokens: Integer
|
||||
interleaved: String?
|
||||
status: String?
|
||||
updated_at: Timestamp
|
||||
|
||||
-- Relationships
|
||||
modalities: AiModelModality with provider = this.provider and model_id = this.model_id
|
||||
|
||||
-- Derived
|
||||
input_modalities: modalities where direction = input -> modality
|
||||
output_modalities: modalities where direction = output -> modality
|
||||
}
|
||||
|
||||
entity AiModelModality {
|
||||
provider: AiCatalogProvider
|
||||
model_id: String
|
||||
direction: input | output
|
||||
modality: text | image | audio | file | tool
|
||||
}
|
||||
|
||||
entity AiCatalogMeta {
|
||||
key: String
|
||||
value: String
|
||||
}
|
||||
|
||||
surface AiEndpointSurface {
|
||||
context endpoint: AiEndpoint
|
||||
|
||||
@@ -28,6 +83,37 @@ surface AiEndpointSurface {
|
||||
endpoint.model
|
||||
}
|
||||
|
||||
surface AiModelSurface {
|
||||
context catalog_model: AiModel
|
||||
|
||||
exposes:
|
||||
catalog_model.provider
|
||||
catalog_model.model_id
|
||||
catalog_model.name
|
||||
catalog_model.family when catalog_model.family != null
|
||||
catalog_model.supports_attachment
|
||||
catalog_model.supports_reasoning
|
||||
catalog_model.supports_tool_calls
|
||||
catalog_model.supports_structured_output
|
||||
catalog_model.supports_temperature
|
||||
catalog_model.knowledge when catalog_model.knowledge != null
|
||||
catalog_model.release_date when catalog_model.release_date != null
|
||||
catalog_model.last_updated_date when catalog_model.last_updated_date != null
|
||||
catalog_model.open_weights
|
||||
catalog_model.input_price when catalog_model.input_price != null
|
||||
catalog_model.output_price when catalog_model.output_price != null
|
||||
catalog_model.cache_read_price when catalog_model.cache_read_price != null
|
||||
catalog_model.cache_write_price when catalog_model.cache_write_price != null
|
||||
catalog_model.context_window
|
||||
catalog_model.max_input_tokens
|
||||
catalog_model.max_output_tokens
|
||||
catalog_model.interleaved when catalog_model.interleaved != null
|
||||
catalog_model.status when catalog_model.status != null
|
||||
catalog_model.input_modalities
|
||||
catalog_model.output_modalities
|
||||
catalog_model.updated_at
|
||||
}
|
||||
|
||||
entity SecureKeyStore {
|
||||
-- Encrypts API keys using the host operating system's secure storage.
|
||||
-- Stored in application settings in encrypted form.
|
||||
@@ -62,8 +148,12 @@ entity ChatMessage {
|
||||
conversation: ChatConversation
|
||||
role: system | user | assistant | tool
|
||||
content: String
|
||||
tool_call_id: String?
|
||||
tool_calls: String?
|
||||
token_usage_input: Integer?
|
||||
token_usage_output: Integer?
|
||||
cache_read_tokens: Integer?
|
||||
cache_write_tokens: Integer?
|
||||
created_at: Timestamp
|
||||
}
|
||||
|
||||
@@ -74,11 +164,24 @@ surface ChatMessageSurface {
|
||||
message.conversation
|
||||
message.role
|
||||
message.content
|
||||
message.tool_call_id when message.tool_call_id != null
|
||||
message.tool_calls when message.tool_calls != null
|
||||
message.token_usage_input when message.token_usage_input != null
|
||||
message.token_usage_output when message.token_usage_output != null
|
||||
message.cache_read_tokens when message.cache_read_tokens != null
|
||||
message.cache_write_tokens when message.cache_write_tokens != null
|
||||
message.created_at
|
||||
}
|
||||
|
||||
surface AiConfigurationSurface {
|
||||
facing _: AiOperator
|
||||
|
||||
provides:
|
||||
SetAiEndpointRequested(kind, url, api_key, model)
|
||||
RemoveAiEndpointRequested(kind)
|
||||
RefreshModelCatalogRequested(source)
|
||||
}
|
||||
|
||||
surface OneShotAiSurface {
|
||||
facing _: AiOperator
|
||||
|
||||
@@ -97,7 +200,31 @@ surface AiChatSurface {
|
||||
provides:
|
||||
StartChatRequested(model)
|
||||
SendChatMessageRequested(conversation, content)
|
||||
RefreshModelCatalogRequested(endpoint)
|
||||
CancelChatRequested(conversation)
|
||||
}
|
||||
|
||||
config {
|
||||
model_catalog_ttl: Duration = 5.minutes
|
||||
chat_max_tool_rounds: Integer = 10
|
||||
default_max_output_tokens: Integer = 16384
|
||||
}
|
||||
|
||||
rule SetAiEndpoint {
|
||||
when: SetAiEndpointRequested(kind, url, api_key, model)
|
||||
ensures:
|
||||
let endpoint = AiEndpoint.created(
|
||||
kind: kind,
|
||||
url: url,
|
||||
api_key: api_key,
|
||||
model: model
|
||||
)
|
||||
endpoint.kind = kind
|
||||
}
|
||||
|
||||
rule RemoveAiEndpoint {
|
||||
when: RemoveAiEndpointRequested(kind)
|
||||
for endpoint in AiEndpoints where endpoint.kind = kind:
|
||||
ensures: not exists endpoint
|
||||
}
|
||||
|
||||
-- One-shot AI tasks (core scope, no streaming)
|
||||
@@ -173,17 +300,24 @@ rule SendChatMessage {
|
||||
ensures: conversation.updated_at = now
|
||||
ensures: AiStreamingResponse(conversation)
|
||||
-- Streaming response with bounded tool-call loop.
|
||||
-- Blog data tools for post/media querying during chat.
|
||||
-- Token usage tracking (input, output, cache read/write).
|
||||
-- Blog data tools for post/media querying and mutation during chat.
|
||||
-- Render tools may emit structured chart/table/form payloads.
|
||||
-- Token usage tracking includes input, output, cache read, cache write.
|
||||
}
|
||||
|
||||
rule CancelChat {
|
||||
when: CancelChatRequested(conversation)
|
||||
ensures: AiStreamingResponseCancelled(conversation)
|
||||
}
|
||||
|
||||
-- Model catalog
|
||||
|
||||
rule RefreshModelCatalog {
|
||||
when: RefreshModelCatalogRequested(endpoint)
|
||||
-- Queries the endpoint's model list API
|
||||
-- 5-minute cache TTL
|
||||
ensures: ModelCatalogUpdated(endpoint)
|
||||
when: RefreshModelCatalogRequested(source)
|
||||
-- Refreshes advisory provider/model metadata used for capability checks,
|
||||
-- default token budgeting, and model selection UX.
|
||||
-- Uses conditional GET with ETag where supported.
|
||||
ensures: ModelCatalogUpdated()
|
||||
}
|
||||
|
||||
invariant AirplaneModeGating {
|
||||
@@ -196,6 +330,14 @@ invariant AirplaneModeGating {
|
||||
-- show toast "AI unavailable — configure {online|airplane} endpoint in Settings"
|
||||
}
|
||||
|
||||
invariant AirplaneModeModelSwap {
|
||||
-- In airplane mode, cloud models are never contacted.
|
||||
-- Chat uses the configured offline chat model when needed.
|
||||
-- Image analysis uses the configured offline vision-capable model when needed.
|
||||
-- If no suitable offline model is configured, the operation fails with
|
||||
-- actionable guidance instead of silently falling back to the online endpoint.
|
||||
}
|
||||
|
||||
invariant TwoEndpointModel {
|
||||
-- Two configurable OpenAI-compatible endpoints:
|
||||
-- online: for cloud providers (requires API key)
|
||||
@@ -204,6 +346,66 @@ invariant TwoEndpointModel {
|
||||
-- Endpoint selection is configurable rather than tied to hard-coded providers.
|
||||
}
|
||||
|
||||
invariant AdvisoryModelCatalog {
|
||||
-- Model metadata is stored separately from runtime endpoint configuration.
|
||||
-- It supplies capability hints such as context window, tool-call support,
|
||||
-- structured output support, vision/input modalities, and pricing metadata.
|
||||
-- The catalog remains usable offline after the last successful refresh.
|
||||
}
|
||||
|
||||
invariant ConditionalCatalogRefresh {
|
||||
-- Model catalog refresh uses conditional HTTP requests when possible.
|
||||
-- The latest ETag and fetch timestamp are persisted in AiCatalogMeta.
|
||||
-- A 304 response updates freshness metadata without rewriting model rows.
|
||||
exists meta in AiCatalogMeta where meta.key = "etag" or meta.key = "last_fetched_at"
|
||||
}
|
||||
|
||||
invariant ProviderDetection {
|
||||
-- Runtime provider selection may be inferred from model identifiers,
|
||||
-- local-endpoint registration, or explicit endpoint configuration.
|
||||
-- The system does not rely on a single hard-coded provider list for routing.
|
||||
}
|
||||
|
||||
invariant VisionCapabilityGate {
|
||||
-- AnalyzeImage only runs against models that accept image input.
|
||||
-- Local/offline models must advertise or be configured with image capability
|
||||
-- before the runtime sends multimodal requests to them.
|
||||
}
|
||||
|
||||
invariant ChatContextTruncation {
|
||||
-- Chat requests are trimmed to fit within the selected model's context window.
|
||||
-- Oldest user/assistant pairs are dropped first.
|
||||
-- The system prompt, tool schema budget, and output-token reserve are preserved.
|
||||
}
|
||||
|
||||
invariant BoundedToolLoop {
|
||||
-- Chat tool execution is bounded by config.chat_max_tool_rounds.
|
||||
-- Tool-capable models may call blog-domain tools and render tools.
|
||||
-- Non-tool-capable models skip tool exposure entirely.
|
||||
}
|
||||
|
||||
invariant TokenUsageAccounting {
|
||||
-- Chat turn accounting tracks input, output, cache-read, and cache-write tokens.
|
||||
-- Usage is reported per turn and accumulated per conversation.
|
||||
-- Cache token accounting is surfaced when the underlying provider reports it.
|
||||
}
|
||||
|
||||
invariant ChatCancellation {
|
||||
-- Each in-flight chat turn can be aborted independently.
|
||||
-- Cancellation stops streaming and tool execution for that request only.
|
||||
}
|
||||
|
||||
invariant StructuredRenderTools {
|
||||
-- Chat may emit structured render payloads for charts, tables, and forms.
|
||||
-- These payloads are data contracts, not arbitrary HTML strings.
|
||||
}
|
||||
|
||||
invariant BlogStatsPromptAugmentation {
|
||||
-- The base system prompt may be augmented with current blog statistics
|
||||
-- such as post counts, media counts, tag/category totals, and date ranges
|
||||
-- so long as the augmentation reflects current project state.
|
||||
}
|
||||
|
||||
invariant AiSpecPartitioning {
|
||||
-- This file covers two distinct but related AI contracts:
|
||||
-- 1. Core one-shot operations (taxonomy, vision, translation, language detection)
|
||||
|
||||
459
test/bds/ai_test.exs
Normal file
459
test/bds/ai_test.exs
Normal file
@@ -0,0 +1,459 @@
|
||||
defmodule BDS.AITest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.Media.Media
|
||||
alias BDS.Persistence
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.Projects
|
||||
alias BDS.Repo
|
||||
alias BDS.Settings.Setting
|
||||
|
||||
defmodule FakeSecretBackend do
|
||||
def encrypt(value), do: {:ok, "enc:" <> value}
|
||||
|
||||
def decrypt("enc:" <> value), do: {:ok, value}
|
||||
def decrypt(_other), do: {:error, :invalid_ciphertext}
|
||||
end
|
||||
|
||||
defmodule FakeHttpClient do
|
||||
def get("https://models.dev/api.json", headers) do
|
||||
send(self(), {:http_headers, headers})
|
||||
|
||||
if Map.has_key?(headers, "if-none-match") do
|
||||
{:ok, %{status: 304, headers: %{"etag" => headers["if-none-match"]}, body: ""}}
|
||||
else
|
||||
{:ok,
|
||||
%{
|
||||
status: 200,
|
||||
headers: %{"etag" => "W/\"catalog-v1\""},
|
||||
body: Jason.encode!(sample_catalog())
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
def sample_catalog do
|
||||
%{
|
||||
"openai" => %{
|
||||
"name" => "OpenAI",
|
||||
"env" => ["OPENAI_API_KEY"],
|
||||
"api" => "https://api.openai.com/v1",
|
||||
"doc" => "https://platform.openai.com/docs",
|
||||
"models" => %{
|
||||
"gpt-4o-mini" => %{
|
||||
"name" => "GPT-4o mini",
|
||||
"family" => "gpt-4o",
|
||||
"attachment" => false,
|
||||
"reasoning" => false,
|
||||
"tool_call" => true,
|
||||
"structured_output" => true,
|
||||
"temperature" => true,
|
||||
"open_weights" => false,
|
||||
"cost" => %{"input" => 15, "output" => 60, "cache_read" => 5, "cache_write" => 10},
|
||||
"limit" => %{"context" => 128_000, "input" => 128_000, "output" => 16_384},
|
||||
"input_modalities" => ["text"],
|
||||
"output_modalities" => ["text"]
|
||||
},
|
||||
"gpt-4o" => %{
|
||||
"name" => "GPT-4o",
|
||||
"family" => "gpt-4o",
|
||||
"attachment" => true,
|
||||
"reasoning" => false,
|
||||
"tool_call" => true,
|
||||
"structured_output" => true,
|
||||
"temperature" => true,
|
||||
"open_weights" => false,
|
||||
"cost" => %{"input" => 250, "output" => 1000},
|
||||
"limit" => %{"context" => 128_000, "input" => 128_000, "output" => 16_384},
|
||||
"input_modalities" => ["text", "image"],
|
||||
"output_modalities" => ["text"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defmodule FakeRuntime do
|
||||
def generate(endpoint, request, opts) do
|
||||
test_pid = Keyword.fetch!(opts, :test_pid)
|
||||
send(test_pid, {:runtime_request, endpoint, request})
|
||||
|
||||
case request.operation do
|
||||
:detect_language ->
|
||||
{:ok, %{json: %{"language_code" => "fr"}, usage: usage(11, 3, 0, 0)}}
|
||||
|
||||
:translate_post ->
|
||||
{:ok,
|
||||
%{
|
||||
json: %{
|
||||
"title" => "Hallo Welt",
|
||||
"excerpt" => "Kurze Zusammenfassung",
|
||||
"content" => "# Hallo Welt\n\nUbersetzter Inhalt"
|
||||
},
|
||||
usage: usage(22, 14, 0, 0)
|
||||
}}
|
||||
|
||||
:analyze_image ->
|
||||
{:ok,
|
||||
%{
|
||||
json: %{
|
||||
"title" => "Sunset",
|
||||
"alt" => "Orange sunset over calm water",
|
||||
"caption" => "Evening light over the bay"
|
||||
},
|
||||
usage: usage(13, 9, 0, 0)
|
||||
}}
|
||||
|
||||
:chat ->
|
||||
if Enum.any?(request.messages, &(&1["role"] == "tool")) do
|
||||
{:ok,
|
||||
%{
|
||||
content: "You currently have 1 post and 1 media item.",
|
||||
usage: usage(64, 21, 3, 1)
|
||||
}}
|
||||
else
|
||||
{:ok,
|
||||
%{
|
||||
tool_calls: [
|
||||
%{
|
||||
id: "call-blog-stats",
|
||||
name: "blog_stats",
|
||||
arguments: %{}
|
||||
}
|
||||
],
|
||||
usage: usage(31, 8, 0, 0)
|
||||
}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp usage(input_tokens, output_tokens, cache_read_tokens, cache_write_tokens) do
|
||||
%{
|
||||
input_tokens: input_tokens,
|
||||
output_tokens: output_tokens,
|
||||
cache_read_tokens: cache_read_tokens,
|
||||
cache_write_tokens: cache_write_tokens
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defmodule BlockingRuntime do
|
||||
def generate(endpoint, request, opts) do
|
||||
test_pid = Keyword.fetch!(opts, :test_pid)
|
||||
send(test_pid, {:blocking_runtime_started, endpoint, request})
|
||||
Process.sleep(5_000)
|
||||
{:ok, %{content: "too late", usage: %{input_tokens: 1, output_tokens: 1}}}
|
||||
end
|
||||
end
|
||||
|
||||
setup do
|
||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||
:ok
|
||||
end
|
||||
|
||||
test "put_endpoint, get_endpoint, and delete_endpoint manage encrypted endpoint settings" do
|
||||
assert {:ok, endpoint} =
|
||||
BDS.AI.put_endpoint(:online, %{
|
||||
url: "https://api.example.test/v1",
|
||||
api_key: "top-secret",
|
||||
model: "gpt-4o-mini"
|
||||
}, secret_backend: FakeSecretBackend)
|
||||
|
||||
assert endpoint.kind == :online
|
||||
assert endpoint.url == "https://api.example.test/v1"
|
||||
assert endpoint.api_key == "top-secret"
|
||||
assert endpoint.model == "gpt-4o-mini"
|
||||
|
||||
assert %Setting{value: "https://api.example.test/v1"} = Repo.get(Setting, "ai.online.url")
|
||||
assert %Setting{value: "gpt-4o-mini"} = Repo.get(Setting, "ai.online.model")
|
||||
assert %Setting{value: encrypted_value} = Repo.get(Setting, "__encrypted_ai.online.api_key")
|
||||
refute encrypted_value == "top-secret"
|
||||
|
||||
assert {:ok, fetched} = BDS.AI.get_endpoint(:online, secret_backend: FakeSecretBackend)
|
||||
assert fetched.api_key == "top-secret"
|
||||
|
||||
assert :ok = BDS.AI.delete_endpoint(:online)
|
||||
assert {:ok, nil} = BDS.AI.get_endpoint(:online, secret_backend: FakeSecretBackend)
|
||||
assert Repo.get(Setting, "ai.online.url") == nil
|
||||
assert Repo.get(Setting, "__encrypted_ai.online.api_key") == nil
|
||||
end
|
||||
|
||||
test "refresh_model_catalog stores providers, models, modalities, and etag metadata" do
|
||||
assert {:ok, result} =
|
||||
BDS.AI.refresh_model_catalog(http_client: FakeHttpClient)
|
||||
|
||||
assert result.success == true
|
||||
assert result.models_updated == 2
|
||||
assert_received {:http_headers, %{"accept" => "application/json"}}
|
||||
|
||||
providers = BDS.AI.list_catalog_providers()
|
||||
assert [%{id: "openai", name: "OpenAI"}] = providers
|
||||
|
||||
assert {:ok, model} = BDS.AI.get_catalog_model("gpt-4o")
|
||||
assert model.provider == "openai"
|
||||
assert model.supports_tool_calls == true
|
||||
assert model.context_window == 128_000
|
||||
assert "image" in model.input_modalities
|
||||
|
||||
modalities =
|
||||
Repo.all(
|
||||
from modality in "ai_model_modalities",
|
||||
where: modality.provider == ^"openai" and modality.model_id == ^"gpt-4o",
|
||||
select: {modality.direction, modality.modality}
|
||||
)
|
||||
|
||||
assert {"input", "image"} in modalities
|
||||
|
||||
assert {:ok, "W/\"catalog-v1\""} = BDS.AI.catalog_meta("etag")
|
||||
assert {:ok, last_fetched_at} = BDS.AI.catalog_meta("last_fetched_at")
|
||||
assert is_binary(last_fetched_at)
|
||||
end
|
||||
|
||||
test "refresh_model_catalog uses conditional fetch metadata on subsequent refreshes" do
|
||||
assert {:ok, _first} = BDS.AI.refresh_model_catalog(http_client: FakeHttpClient)
|
||||
|
||||
http_client = fn url, headers ->
|
||||
send(self(), {:conditional_headers, headers})
|
||||
FakeHttpClient.get(url, Map.put(headers, "if-none-match", "W/\"catalog-v1\""))
|
||||
end
|
||||
|
||||
assert {:ok, result} = BDS.AI.refresh_model_catalog(http_client: http_client)
|
||||
assert result.not_modified == true
|
||||
assert_received {:conditional_headers, %{"accept" => "application/json", "if-none-match" => "W/\"catalog-v1\""}}
|
||||
end
|
||||
|
||||
test "airplane mode routes title tasks to airplane endpoint and offline title model" do
|
||||
assert {:ok, _endpoint} =
|
||||
BDS.AI.put_endpoint(:online, %{
|
||||
url: "https://api.example.test/v1",
|
||||
api_key: "online-secret",
|
||||
model: "gpt-4o-mini"
|
||||
}, secret_backend: FakeSecretBackend)
|
||||
|
||||
assert {:ok, _endpoint} =
|
||||
BDS.AI.put_endpoint(:airplane, %{
|
||||
url: "http://localhost:11434/v1",
|
||||
api_key: nil,
|
||||
model: "llama-default"
|
||||
}, secret_backend: FakeSecretBackend)
|
||||
|
||||
assert :ok = BDS.AI.set_airplane_mode(true)
|
||||
assert :ok = BDS.AI.put_model_preference(:airplane_title, "llama3.1")
|
||||
|
||||
assert {:ok, result} =
|
||||
BDS.AI.detect_language("Bonjour tout le monde",
|
||||
runtime: FakeRuntime,
|
||||
test_pid: self(),
|
||||
secret_backend: FakeSecretBackend
|
||||
)
|
||||
|
||||
assert result.language_code == "fr"
|
||||
|
||||
assert_received {:runtime_request, endpoint, request}
|
||||
assert endpoint.kind == :airplane
|
||||
assert endpoint.url == "http://localhost:11434/v1"
|
||||
assert request.operation == :detect_language
|
||||
assert request.model == "llama3.1"
|
||||
end
|
||||
|
||||
test "translate_post uses the online title model when airplane mode is disabled" do
|
||||
assert {:ok, _endpoint} =
|
||||
BDS.AI.put_endpoint(:online, %{
|
||||
url: "https://api.example.test/v1",
|
||||
api_key: "online-secret",
|
||||
model: "gpt-4o-mini"
|
||||
}, secret_backend: FakeSecretBackend)
|
||||
|
||||
assert :ok = BDS.AI.set_airplane_mode(false)
|
||||
assert :ok = BDS.AI.put_model_preference(:title, "gpt-4.1-mini")
|
||||
|
||||
assert {:ok, translation} =
|
||||
BDS.AI.translate_post(
|
||||
%{title: "Hello World", excerpt: "Short summary", content: "# Hello\n\nSource body"},
|
||||
"de",
|
||||
runtime: FakeRuntime,
|
||||
test_pid: self(),
|
||||
secret_backend: FakeSecretBackend
|
||||
)
|
||||
|
||||
assert translation.title == "Hallo Welt"
|
||||
assert translation.excerpt == "Kurze Zusammenfassung"
|
||||
|
||||
assert_received {:runtime_request, endpoint, request}
|
||||
assert endpoint.kind == :online
|
||||
assert request.operation == :translate_post
|
||||
assert request.model == "gpt-4.1-mini"
|
||||
end
|
||||
|
||||
test "analyze_image requires a vision-capable airplane model before sending image input" do
|
||||
assert {:ok, _endpoint} =
|
||||
BDS.AI.put_endpoint(:airplane, %{
|
||||
url: "http://localhost:11434/v1",
|
||||
api_key: nil,
|
||||
model: "llama-default"
|
||||
}, secret_backend: FakeSecretBackend)
|
||||
|
||||
assert :ok = BDS.AI.set_airplane_mode(true)
|
||||
assert :ok = BDS.AI.put_model_preference(:airplane_image_analysis, "llama3.2")
|
||||
|
||||
assert {:error, %{kind: :model_capability_missing}} =
|
||||
BDS.AI.analyze_image(%{
|
||||
mime_type: "image/png",
|
||||
title: "Source",
|
||||
alt: nil,
|
||||
caption: nil,
|
||||
image_url: "file:///tmp/test.png"
|
||||
}, runtime: FakeRuntime, test_pid: self(), secret_backend: FakeSecretBackend)
|
||||
|
||||
assert :ok =
|
||||
BDS.AI.put_model_capabilities("llama3.2", %{
|
||||
supports_attachment: true,
|
||||
supports_tool_calls: false
|
||||
})
|
||||
|
||||
assert {:ok, analysis} =
|
||||
BDS.AI.analyze_image(%{
|
||||
mime_type: "image/png",
|
||||
title: "Source",
|
||||
alt: nil,
|
||||
caption: nil,
|
||||
image_url: "file:///tmp/test.png"
|
||||
}, runtime: FakeRuntime, test_pid: self(), secret_backend: FakeSecretBackend)
|
||||
|
||||
assert analysis.alt == "Orange sunset over calm water"
|
||||
|
||||
assert_received {:runtime_request, endpoint, request}
|
||||
assert endpoint.kind == :airplane
|
||||
assert request.operation == :analyze_image
|
||||
assert request.model == "llama3.2"
|
||||
end
|
||||
|
||||
test "chat persists user, tool, and assistant messages with usage and blog stats prompt augmentation" do
|
||||
{:ok, project} = create_project_fixture("AI Chat")
|
||||
:ok = seed_project_content(project.id)
|
||||
|
||||
assert {:ok, _endpoint} =
|
||||
BDS.AI.put_endpoint(:online, %{
|
||||
url: "https://api.example.test/v1",
|
||||
api_key: "online-secret",
|
||||
model: "gpt-4o-mini"
|
||||
}, secret_backend: FakeSecretBackend)
|
||||
|
||||
assert :ok = BDS.AI.set_airplane_mode(false)
|
||||
assert {:ok, conversation} = BDS.AI.start_chat(%{model: "gpt-4o-mini"})
|
||||
|
||||
assert {:ok, reply} =
|
||||
BDS.AI.send_chat_message(conversation.id, "How many items are in the blog?",
|
||||
runtime: FakeRuntime,
|
||||
test_pid: self(),
|
||||
project_id: project.id,
|
||||
secret_backend: FakeSecretBackend
|
||||
)
|
||||
|
||||
assert reply.assistant_message.content == "You currently have 1 post and 1 media item."
|
||||
|
||||
messages = BDS.AI.list_chat_messages(conversation.id)
|
||||
assert Enum.map(messages, & &1.role) == [:user, :assistant, :tool, :assistant]
|
||||
|
||||
assistant_tool_call = Enum.at(messages, 1)
|
||||
tool_message = Enum.at(messages, 2)
|
||||
assistant_message = Enum.at(messages, 3)
|
||||
|
||||
assert [%{"id" => "call-blog-stats", "name" => "blog_stats"}] = assistant_tool_call.tool_calls
|
||||
assert tool_message.tool_call_id == "call-blog-stats"
|
||||
assert tool_message.content =~ "post_count"
|
||||
assert assistant_message.token_usage_input == 64
|
||||
assert assistant_message.token_usage_output == 21
|
||||
assert assistant_message.cache_read_tokens == 3
|
||||
assert assistant_message.cache_write_tokens == 1
|
||||
|
||||
assert_received {:runtime_request, _endpoint, first_request}
|
||||
assert_received {:runtime_request, _endpoint, second_request}
|
||||
|
||||
assert Enum.any?(first_request.messages, fn message ->
|
||||
message["role"] == "system" and String.contains?(message["content"], "Posts: 1") and
|
||||
String.contains?(message["content"], "Media: 1")
|
||||
end)
|
||||
|
||||
assert Enum.any?(second_request.messages, fn message -> message["role"] == "tool" end)
|
||||
end
|
||||
|
||||
test "cancel_chat aborts an in-flight chat turn" do
|
||||
assert {:ok, _endpoint} =
|
||||
BDS.AI.put_endpoint(:online, %{
|
||||
url: "https://api.example.test/v1",
|
||||
api_key: "online-secret",
|
||||
model: "gpt-4o-mini"
|
||||
}, secret_backend: FakeSecretBackend)
|
||||
|
||||
assert {:ok, conversation} = BDS.AI.start_chat(%{model: "gpt-4o-mini"})
|
||||
|
||||
parent = self()
|
||||
|
||||
task =
|
||||
Task.async(fn ->
|
||||
BDS.AI.send_chat_message(conversation.id, "Please wait",
|
||||
runtime: BlockingRuntime,
|
||||
test_pid: parent,
|
||||
secret_backend: FakeSecretBackend
|
||||
)
|
||||
end)
|
||||
|
||||
assert_receive {:blocking_runtime_started, _endpoint, request}, 500
|
||||
assert request.operation == :chat
|
||||
|
||||
assert :ok = BDS.AI.cancel_chat(conversation.id)
|
||||
assert {:error, :cancelled} = Task.await(task)
|
||||
|
||||
messages = BDS.AI.list_chat_messages(conversation.id)
|
||||
assert Enum.map(messages, & &1.role) == [:user]
|
||||
end
|
||||
|
||||
defp create_project_fixture(name) do
|
||||
temp_dir = Path.join(System.tmp_dir!(), "bds-ai-#{System.unique_integer([:positive])}")
|
||||
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||
|
||||
with {:ok, project} <- Projects.create_project(%{name: name, data_path: temp_dir}),
|
||||
{:ok, _active} <- Projects.set_active_project(project.id) do
|
||||
{:ok, project}
|
||||
end
|
||||
end
|
||||
|
||||
defp seed_project_content(project_id) do
|
||||
now = Persistence.now_ms()
|
||||
|
||||
Repo.insert!(
|
||||
Post.changeset(%Post{}, %{
|
||||
id: Ecto.UUID.generate(),
|
||||
project_id: project_id,
|
||||
title: "AI Post",
|
||||
slug: "ai-post",
|
||||
excerpt: "Summary",
|
||||
content: "Body",
|
||||
status: :draft,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
do_not_translate: false
|
||||
})
|
||||
)
|
||||
|
||||
Repo.insert!(
|
||||
Media.changeset(%Media{}, %{
|
||||
id: Ecto.UUID.generate(),
|
||||
project_id: project_id,
|
||||
filename: "image.png",
|
||||
original_name: "image.png",
|
||||
mime_type: "image/png",
|
||||
size: 128,
|
||||
file_path: "media/image.png",
|
||||
sidecar_path: "media/image.png.meta",
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
})
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
@@ -148,6 +148,10 @@ defmodule BDS.Repo.SchemaMigrationTest do
|
||||
"content",
|
||||
"tool_call_id",
|
||||
"tool_calls",
|
||||
"token_usage_input",
|
||||
"token_usage_output",
|
||||
"cache_read_tokens",
|
||||
"cache_write_tokens",
|
||||
"created_at"
|
||||
],
|
||||
"ai_providers" => ["id", "name", "env", "package_ref", "api", "doc", "updated_at"],
|
||||
|
||||
Reference in New Issue
Block a user