diff --git a/migration/db/migrations/20261016100500000_alter_signage_ai_for_maps.sql b/migration/db/migrations/20261016100500000_alter_signage_ai_for_maps.sql new file mode 100644 index 00000000..77a62ee9 --- /dev/null +++ b/migration/db/migrations/20261016100500000_alter_signage_ai_for_maps.sql @@ -0,0 +1,26 @@ +-- +micrate Up +-- SQL in section 'Up' is executed when this migration is applied + +-- The text/vision model a provider row uses for map editing. NULL means the +-- adapter's default for that vendor. +ALTER TABLE "signage_ai_providers" ADD COLUMN IF NOT EXISTS text_model TEXT; + +-- MAP_EDIT jobs share the table with the image kinds: one vendor call each, +-- the operations land in result->'ops'. +ALTER TABLE "signage_ai_jobs" DROP CONSTRAINT IF EXISTS signage_ai_jobs_kind_check; +ALTER TABLE "signage_ai_jobs" ADD CONSTRAINT signage_ai_jobs_kind_check + CHECK (kind IN ('GENERATE', 'EDIT', 'MAP_EDIT')); + +CREATE INDEX IF NOT EXISTS signage_ai_jobs_kind_created_index + ON "signage_ai_jobs" USING BTREE (kind, created_at); + +-- +micrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +DROP INDEX IF EXISTS signage_ai_jobs_kind_created_index; + +ALTER TABLE "signage_ai_jobs" DROP CONSTRAINT IF EXISTS signage_ai_jobs_kind_check; +ALTER TABLE "signage_ai_jobs" ADD CONSTRAINT signage_ai_jobs_kind_check + CHECK (kind IN ('GENERATE', 'EDIT')); + +ALTER TABLE "signage_ai_providers" DROP COLUMN IF EXISTS text_model; diff --git a/migration/db/migrations/20261016110500000_alter_signage_ai_providers_anthropic.sql b/migration/db/migrations/20261016110500000_alter_signage_ai_providers_anthropic.sql new file mode 100644 index 00000000..790e3dcf --- /dev/null +++ b/migration/db/migrations/20261016110500000_alter_signage_ai_providers_anthropic.sql @@ -0,0 +1,15 @@ +-- +micrate Up +-- SQL in section 'Up' is executed when this migration is applied + +-- Anthropic joins the vendors a provider row can point at; its rows carry an +-- api_key in credentials and a text_model, and cannot generate images. +ALTER TABLE "signage_ai_providers" DROP CONSTRAINT IF EXISTS signage_ai_providers_provider_check; +ALTER TABLE "signage_ai_providers" ADD CONSTRAINT signage_ai_providers_provider_check + CHECK (provider IN ('OPENAI', 'AZURE_OPENAI', 'GOOGLE_VERTEX', 'ANTHROPIC')); + +-- +micrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +ALTER TABLE "signage_ai_providers" DROP CONSTRAINT IF EXISTS signage_ai_providers_provider_check; +ALTER TABLE "signage_ai_providers" ADD CONSTRAINT signage_ai_providers_provider_check + CHECK (provider IN ('OPENAI', 'AZURE_OPENAI', 'GOOGLE_VERTEX')); diff --git a/spec/generator.cr b/spec/generator.cr index 1671c559..0bea5719 100644 --- a/spec/generator.cr +++ b/spec/generator.cr @@ -751,6 +751,7 @@ module PlaceOS::Model is_default : Bool = false, enabled : Bool = true, quotas : Hash(String, JSON::Any) = {} of String => JSON::Any, + text_model : String? = nil, ) row = SignageAIProvider.new row.name = name || Faker::Hacker.noun @@ -758,6 +759,7 @@ module PlaceOS::Model row.authority_id = authority.try(&.id.as(String)) row.credentials = credentials row.default_model = provider.google? ? "gemini-3.1-flash-image" : "gpt-image-2" + row.text_model = text_model row.is_default = is_default row.enabled = enabled row.quotas = JSON::Any.new(quotas) @@ -788,12 +790,17 @@ module PlaceOS::Model job.state = state job.candidates = candidates job.parent_job_id = parent_job_id - job.prompt = "a poster for the office party" - job.result = JSON::Any.new({"images" => JSON::Any.new(Array(JSON::Any).new(candidates) { JSON::Any.new(nil) })}) + if kind.map_edit? + job.prompt = "recolour the boardroom light blue" + job.result = JSON::Any.new({"ops" => JSON::Any.new([] of JSON::Any)}) + else + job.prompt = "a poster for the office party" + job.result = JSON::Any.new({"images" => JSON::Any.new(Array(JSON::Any).new(candidates) { JSON::Any.new(nil) })}) + end if provider job.provider_id = provider.id.as(UUID) job.provider_type = provider.provider.to_s - job.model = provider.default_model + job.model = kind.map_edit? ? (provider.text_model || "gpt-5.4-mini") : provider.default_model end job end diff --git a/spec/signage_ai_job_spec.cr b/spec/signage_ai_job_spec.cr index 188f16f1..6fac15d8 100644 --- a/spec/signage_ai_job_spec.cr +++ b/spec/signage_ai_job_spec.cr @@ -69,6 +69,42 @@ module PlaceOS::Model SignageAIJob.sum_candidates(user.id.as(String), 1.minute.from_now).should eq 0 end + it "filters the quota sums by kind" do + authority = Generator.localhost_authority + user = Generator.user(authority: authority).save! + Generator.signage_ai_job(authority: authority, user: user, candidates: 3).save! + Generator.signage_ai_job(authority: authority, user: user, candidates: 1, kind: SignageAIJob::Kind::MapEdit).save! + Generator.signage_ai_job(authority: authority, user: user, candidates: 1, kind: SignageAIJob::Kind::MapEdit, state: SignageAIJob::State::Failed).save! + + since = 1.day.ago + SignageAIJob.sum_candidates(user.id.as(String), since).should eq 5 + SignageAIJob.sum_candidates(user.id.as(String), since, SignageAIJob::Kind::MapEdit).should eq 2 + SignageAIJob.sum_candidates(user.id.as(String), since, SignageAIJob::Kind::Generate).should eq 3 + SignageAIJob.sum_candidates_for_authority(authority.id.as(String), since, SignageAIJob::Kind::MapEdit).should eq 2 + SignageAIJob.sum_candidates_for_authority(authority.id.as(String), since, SignageAIJob::Kind::Edit).should eq 0 + + # a list of kinds, as the image quota asks for + SignageAIJob.sum_candidates(user.id.as(String), since, SignageAIJob::Kind.image).should eq 3 + SignageAIJob.sum_candidates_for_authority(authority.id.as(String), since, SignageAIJob::Kind.image).should eq 3 + SignageAIJob.sum_candidates(user.id.as(String), since, [SignageAIJob::Kind::Generate, SignageAIJob::Kind::MapEdit]).should eq 5 + SignageAIJob.sum_candidates(user.id.as(String), since, [] of SignageAIJob::Kind).should eq 5 + end + + it "stores a map edit and reads it back" do + job = Generator.signage_ai_job(kind: SignageAIJob::Kind::MapEdit, candidates: 1).save! + found = SignageAIJob.find!(job.id.as(UUID)) + found.kind.should eq SignageAIJob::Kind::MapEdit + found.kind.to_s.should eq "map_edit" + found.kind.image?.should be_false + SignageAIJob::Kind::Edit.image?.should be_true + SignageAIJob::Kind.image.should eq [SignageAIJob::Kind::Generate, SignageAIJob::Kind::Edit] + SignageAIJob.kind_label(SignageAIJob::Kind::MapEdit).should eq "MAP_EDIT" + + rendered = found.to_json + rendered.should contain %("kind":"map_edit") + SignageAIJob.from_trusted_json(rendered).kind.should eq SignageAIJob::Kind::MapEdit + end + it "walks a refine chain oldest first" do first = Generator.signage_ai_job.save! second = Generator.signage_ai_job(parent_job_id: first.id.as(UUID)).save! @@ -108,6 +144,7 @@ module PlaceOS::Model rows = SignageAIJob.usage(authority.id.as(String), 1.day.ago, 1.day.from_now) rows.size.should eq 1 row = rows.first + row.kind.should eq "generate" row.provider.should eq "OPENAI" row.model.should eq "gpt-image-2" row.jobs.should eq 2 @@ -116,6 +153,29 @@ module PlaceOS::Model row.cost_units.should eq 1.0 end + it "separates map edits from image jobs in the usage report" do + authority = Generator.localhost_authority + provider = Generator.signage_ai_provider(authority: authority, text_model: "gpt-5.4-mini").save! + Generator.signage_ai_job(authority: authority, provider: provider, candidates: 2).save! + map_job = Generator.signage_ai_job(authority: authority, provider: provider, candidates: 1, kind: SignageAIJob::Kind::MapEdit).save! + map_job.cost_units = 4321.0 + map_job.save! + + rows = SignageAIJob.usage(authority.id.as(String), 1.day.ago, 1.day.from_now) + rows.map(&.kind).should eq ["generate", "map_edit"] + rows.last.model.should eq "gpt-5.4-mini" + rows.last.candidates.should eq 1 + rows.last.images_produced.should eq 0 + rows.last.cost_units.should eq 4321.0 + + only_maps = SignageAIJob.usage(authority.id.as(String), 1.day.ago, 1.day.from_now, SignageAIJob::Kind::MapEdit) + only_maps.map(&.kind).should eq ["map_edit"] + only_maps.first.to_json.should contain %("kind":"map_edit") + + images = SignageAIJob.usage(authority.id.as(String), 1.day.ago, 1.day.from_now, SignageAIJob::Kind.image) + images.map(&.kind).should eq ["generate"] + end + describe "automatic cleanup" do # cleanup ships off, so every example that exercises it has to turn it on before_each { SignageAIJob.cleanup_after = 90.days } diff --git a/spec/signage_ai_provider_spec.cr b/spec/signage_ai_provider_spec.cr index 13460a91..e038894b 100644 --- a/spec/signage_ai_provider_spec.cr +++ b/spec/signage_ai_provider_spec.cr @@ -41,9 +41,9 @@ module PlaceOS::Model authority = Generator.localhost_authority other = Generator.authority(domain: "http://default-#{UUID.random}.test").save! - first = Generator.signage_ai_provider(authority: authority, is_default: true).save! - elsewhere = Generator.signage_ai_provider(authority: other, is_default: true).save! - second = Generator.signage_ai_provider(authority: authority, is_default: true).save! + first = Generator.signage_ai_provider(name: "first", authority: authority, is_default: true).save! + elsewhere = Generator.signage_ai_provider(name: "elsewhere", authority: other, is_default: true).save! + second = Generator.signage_ai_provider(name: "second", authority: authority, is_default: true).save! SignageAIProvider.find!(first.id.as(UUID)).is_default.should be_false SignageAIProvider.find!(second.id.as(UUID)).is_default.should be_true @@ -72,6 +72,17 @@ module PlaceOS::Model SignageAIProvider.default_for(authority.id.as(String)).try(&.id).should eq shared.id end + it "stores the text model and renders it" do + row = Generator.signage_ai_provider(text_model: "gpt-5.4-mini").save! + found = SignageAIProvider.find!(row.id.as(UUID)) + found.text_model.should eq "gpt-5.4-mini" + found.as_json[:text_model].should eq "gpt-5.4-mini" + + bare = Generator.signage_ai_provider.save! + SignageAIProvider.find!(bare.id.as(UUID)).text_model.should be_nil + bare.as_json[:text_model].should be_nil + end + it "reads quotas" do row = Generator.signage_ai_provider(quotas: {"user_per_day" => JSON::Any.new(12_i64)}).save! SignageAIProvider.find!(row.id.as(UUID)).quota("user_per_day").should eq 12 diff --git a/src/placeos-models/signage_ai_job.cr b/src/placeos-models/signage_ai_job.cr index 91c14851..2b7dad42 100644 --- a/src/placeos-models/signage_ai_job.cr +++ b/src/placeos-models/signage_ai_job.cr @@ -8,10 +8,11 @@ require "./signage_ai_provider" require "./user" module PlaceOS::Model - # One image generation or edit request. + # One image generation or edit request, or one map edit request. # # Candidates are written into `result["images"]` one at a time by the runner as - # each vendor call finishes. Every write bumps `version`, which is what the long + # each vendor call finishes. A map edit is a single vendor call whose operations + # land in `result["ops"]`. Every write bumps `version`, which is what the long # polling endpoint compares against so a client can tell what it has already seen # (`updated_at` only has whole second resolution and cannot order two writes in # the same second). @@ -51,9 +52,19 @@ module PlaceOS::Model enum Kind Generate Edit + MapEdit def to_s : String - super.downcase + super.underscore + end + + # the kinds that produce images + def image? : Bool + generate? || edit? + end + + def self.image : Array(Kind) + [Generate, Edit] end end @@ -213,33 +224,61 @@ module PlaceOS::Model end # Candidates requested by a user since a point in time, for the per user quota. + # `kind` narrows the sum to one kind or a list of kinds; nil sums every kind. # # Failed jobs count. Most of them reached the vendor and were billed, and # exempting them meant a caller whose requests kept failing had no limit at # all, which is the one case where a limit matters most. - def self.sum_candidates(user_id : String, since : Time) : Int32 + def self.sum_candidates(user_id : String, since : Time, kind : Kind | Array(Kind) | Nil = nil) : Int32 + filter, labels = kind_filter(kind, 3) + args = [user_id, since] of ::PgORM::Value + args.concat(labels) ::PgORM::Database.connection do |db| - db.query_one(<<-SQL, user_id, since, as: Int64) + db.query_one(<<-SQL, args: args, as: Int64) SELECT COALESCE(SUM(candidates), 0)::bigint FROM signage_ai_jobs WHERE user_id = $1 AND created_at >= $2 + #{filter} SQL end.to_i32 end # Candidates requested across a domain since a point in time, for the domain quota. - def self.sum_candidates_for_authority(authority_id : String, since : Time) : Int32 + def self.sum_candidates_for_authority(authority_id : String, since : Time, kind : Kind | Array(Kind) | Nil = nil) : Int32 + filter, labels = kind_filter(kind, 3) + args = [authority_id, since] of ::PgORM::Value + args.concat(labels) ::PgORM::Database.connection do |db| - db.query_one(<<-SQL, authority_id, since, as: Int64) + db.query_one(<<-SQL, args: args, as: Int64) SELECT COALESCE(SUM(candidates), 0)::bigint FROM signage_ai_jobs WHERE authority_id = $1 AND created_at >= $2 + #{filter} SQL end.to_i32 end + # the label `PGEnumConverter` stores for a kind, for raw SQL + def self.kind_label(kind : Kind) : String + kind.to_s.underscore.upcase + end + + # An `AND kind IN (...)` clause with its bound labels, placeholders numbered + # from `first`; empty for nil. + private def self.kind_filter(kind : Kind | Array(Kind) | Nil, first : Int32) : Tuple(String, Array(::PgORM::Value)) + kinds = case kind + in Kind then [kind] + in Array(Kind) then kind + in Nil then [] of Kind + end + return {"", [] of ::PgORM::Value} if kinds.empty? + + placeholders = kinds.map_with_index { |_, index| "$#{first + index}" }.join(", ") + {"AND kind IN (#{placeholders})", kinds.map { |entry| kind_label(entry).as(::PgORM::Value) }} + end + # Housekeeping, run as each new job is created. Does nothing unless an # operator has set a retention window. # @@ -313,17 +352,21 @@ module PlaceOS::Model where("state = 'RUNNING' AND started_at < ?", older_than).to_a end - # Usage per provider and model, for the Backoffice usage tab. - record UsageRow, provider : String, model : String, jobs : Int64, candidates : Int64, + # Usage per kind, provider and model, for the Backoffice usage tab. + record UsageRow, kind : String, provider : String, model : String, jobs : Int64, candidates : Int64, images_produced : Int64, cost_units : Float64 do include JSON::Serializable end - def self.usage(authority_id : String, from : Time, to : Time) : Array(UsageRow) + def self.usage(authority_id : String, from : Time, to : Time, kind : Kind | Array(Kind) | Nil = nil) : Array(UsageRow) rows = [] of UsageRow + filter, labels = kind_filter(kind, 4) + args = [authority_id, from, to] of ::PgORM::Value + args.concat(labels) ::PgORM::Database.connection do |db| - db.query(<<-SQL, authority_id, from, to) do |rs| + db.query(<<-SQL, args: args) do |rs| SELECT + LOWER(kind) AS kind, COALESCE(provider_type, 'unknown') AS provider, COALESCE(model, 'unknown') AS model, COUNT(*)::bigint AS jobs, @@ -331,12 +374,13 @@ module PlaceOS::Model COALESCE(SUM(images_produced), 0)::bigint AS images_produced, COALESCE(SUM(cost_units), 0)::double precision AS cost_units FROM signage_ai_jobs - WHERE authority_id = $1 AND created_at >= $2 AND created_at < $3 - GROUP BY 1, 2 - ORDER BY 1, 2 + WHERE authority_id = $1 AND created_at >= $2 AND created_at < $3 #{filter} + GROUP BY 1, 2, 3 + ORDER BY 1, 2, 3 SQL rs.each do rows << UsageRow.new( + kind: rs.read(String), provider: rs.read(String), model: rs.read(String), jobs: rs.read(Int64), diff --git a/src/placeos-models/signage_ai_provider.cr b/src/placeos-models/signage_ai_provider.cr index b6a06f6d..131f3357 100644 --- a/src/placeos-models/signage_ai_provider.cr +++ b/src/placeos-models/signage_ai_provider.cr @@ -7,7 +7,7 @@ require "./authority" require "./utilities/encryption" module PlaceOS::Model - # Vendor credentials used to generate or edit signage artwork. + # Vendor credentials used to generate or edit signage artwork and to edit maps. # # A row with a `nil` authority_id is the shared fallback, the same arrangement # `Storage` uses. Credentials are a JSON object whose shape depends on the @@ -26,11 +26,16 @@ module PlaceOS::Model OPENAI AZURE_OPENAI GOOGLE_VERTEX + ANTHROPIC def google? self == GOOGLE_VERTEX end + def anthropic? + self == ANTHROPIC + end + # OpenAI and Azure OpenAI speak the same request and response shape def openai_compatible? self == OPENAI || self == AZURE_OPENAI @@ -51,12 +56,14 @@ module PlaceOS::Model attribute endpoint : String? = nil, sanitize: :text attribute location : String? = nil, sanitize: :text attribute default_model : String? = nil, sanitize: :text + # the text/vision model used for map editing, nil for the adapter's default + attribute text_model : String? = nil, sanitize: :text attribute allowed_models : Array(String) = [] of String, es_type: "keyword" attribute enabled : Bool = true attribute is_default : Bool = false - # {"user_per_day": 60, "domain_per_month": 2000} + # {"user_per_day": 60, "domain_per_month": 2000, "map_user_per_day": 40, "map_domain_per_month": 1000} attribute quotas : JSON::Any = JSON::Any.new({} of String => JSON::Any), es_ignore: true validates :name, presence: true @@ -118,6 +125,7 @@ module PlaceOS::Model endpoint: endpoint, location: location, default_model: default_model, + text_model: text_model, allowed_models: allowed_models, enabled: enabled, is_default: is_default,