Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
f917ad6
refactor(signage-ai): share the credential helpers and the job long poll
camreeves Oct 12, 2026
0844ef0
feat(zones): zone_updatable? gate without the signage grant
camreeves Oct 12, 2026
861d820
feat(map-ai): Llm namespace, OpenAI chat adapter and the op schema
camreeves Oct 12, 2026
1e82518
feat(map-ai): maps/ai routes, provider text model and text probe
camreeves Oct 12, 2026
3eec16b
test(map-ai): vendor stubs, controller, prompt, schema and ops specs
camreeves Oct 12, 2026
5004284
chore(docs): regenerate OPENAPI_DOC.yml for the maps/ai routes
camreeves Oct 12, 2026
367e36b
feat(map-ai): one op contract with the editor, retry a cut-off answer
camreeves Oct 12, 2026
54fafd3
fix(map-ai): expose server issues, reserve the slot last, replay keys…
camreeves Oct 12, 2026
1ab0b59
fix(signage-ai): count only the image kinds against the image allowance
camreeves Oct 12, 2026
97fa985
fix(signage-ai): fail the text probe when the model answers nothing
camreeves Oct 12, 2026
cd477c9
chore(map-ai): comments explain rather than justify
camreeves Oct 12, 2026
e317972
chore(docs): regenerate OPENAPI_DOC.yml for server_issues
camreeves Oct 12, 2026
4463bb4
feat(map-ai): name rooms by number, rename outlined labels, ask less
camreeves Oct 13, 2026
e78ea7b
feat(map-ai): list the outlined labels the editor found
camreeves Oct 13, 2026
536f3fe
feat(map-ai): rename recipe keeps the label's rows and first baseline
camreeves Oct 13, 2026
0518d77
feat(map-ai): treat the stacked layers of one drawn object as a piece
camreeves Oct 13, 2026
b196f3a
feat(map-ai): Anthropic provider, a model list per provider, label an…
camreeves Oct 13, 2026
deac7c5
fix(map-ai): send configured Anthropic workspace header
camreeves Oct 13, 2026
5e9ecee
fix(map-ai): split nullable enums in Anthropic output schemas
camreeves Oct 13, 2026
0957e50
fix(map-ai): share nullable Anthropic schema definitions
camreeves Oct 13, 2026
6c2ea6e
fix(map-ai): simplify Anthropic target references
camreeves Oct 13, 2026
2a96907
fix(map-ai): receive Anthropic proposals through tool inputs
camreeves Oct 13, 2026
e32c10a
fix(map-ai): preserve label contrast and furniture grouping
camreeves Oct 13, 2026
948e972
fix(map-ai): resolve generated targets and avoid overlapping labels
camreeves Oct 13, 2026
3e89c9c
fix(map-ai): validate all explicit duplicate ids before registering c…
camreeves Oct 13, 2026
5f326e8
Merge remote-tracking branch 'origin/master' into PPT-2832-ai-map-review
camreeves Oct 13, 2026
3623b2d
build(map-ai): pin review models dependency and update API docs
camreeves Oct 13, 2026
f49b127
style(map-ai): satisfy Crystal test lint checks
camreeves Oct 13, 2026
eb8899a
chore: pin reviewed map models fixture fix
camreeves Oct 13, 2026
422e1fb
fix(map-ai): sequential renames, canvas expansion and SD replay promp…
camreeves Oct 15, 2026
0e35de3
fix(map-ai): report a cut-off Anthropic proposal as length
camreeves Oct 15, 2026
ee22167
fix(map-ai): check canvas expansion bounds on the server
camreeves Oct 15, 2026
83c4edd
fix(map-ai): keep the snapshot bounds line under the annotations heading
camreeves Oct 15, 2026
26cae42
refactor(ai): share OpenAI and Azure plumbing between the image and m…
camreeves Oct 15, 2026
3450a35
refactor(ai): share quota, cancel and job listing between the AI cont…
camreeves Oct 15, 2026
b7981c0
merge: update the AI map editing branch with master
camreeves Oct 15, 2026
558fa2a
chore: pin the models review branch with the renumbered migrations
camreeves Oct 15, 2026
5a8afee
fix(map-ai): release the call slot before the job is final
camreeves Oct 15, 2026
59359c6
fix(map-ai): read an ops list Claude sent as a JSON string
camreeves Oct 16, 2026
b59aac7
merge: update the AI map editing branch with master
camreeves Oct 19, 2026
2cd6a79
build(map-ai): depend on the released models 9.120.0
camreeves Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,183 changes: 1,140 additions & 43 deletions OPENAPI_DOC.yml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion shard.lock
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ shards:

placeos-models:
git: https://github.com/placeos/models.git
version: 9.119.5
version: 9.120.0

placeos-resource:
git: https://github.com/place-labs/resource.git
Expand Down
2 changes: 1 addition & 1 deletion shard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ dependencies:
# Database view
placeos-models:
github: placeos/models
version: ">= 9.5"
version: ">= 9.120"

# An abstraction over Fibers
promise:
Expand Down
897 changes: 897 additions & 0 deletions spec/controllers/map_ai_spec.cr

Large diffs are not rendered by default.

61 changes: 61 additions & 0 deletions spec/controllers/signage/signage_ai_providers_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ module PlaceOS::Api
authority_id: authority.id,
credentials: {api_key: "sk-spec-secret"},
default_model: "gpt-image-2",
text_model: "gpt-5.4-mini",
is_default: true,
}.to_json

Expand All @@ -39,6 +40,7 @@ module PlaceOS::Api
rendered = JSON.parse(result.body)
rendered["provider"].as_s.should eq "OPENAI"
rendered["is_default"].as_bool.should be_true
rendered["text_model"].as_s.should eq "gpt-5.4-mini"

row = Model::SignageAIProvider.find!(UUID.new(rendered["id"].as_s))
row.credentials_encrypted?.should be_true
Expand All @@ -51,6 +53,7 @@ module PlaceOS::Api
authority: authority,
name: "openai-#{random_name}",
credentials: %({"api_key":"keep-me"}),
text_model: "gpt-5.4-mini",
).save!

result = client.patch(
Expand All @@ -66,6 +69,12 @@ module PlaceOS::Api
row.name.should eq "renamed"
row.enabled.should be_false
row.credentials_json["api_key"].as_s.should eq "keep-me"
# left out of the body, left alone
row.text_model.should eq "gpt-5.4-mini"

# sent empty, cleared
client.patch(File.join(base, provider.id.to_s), headers: Spec::Authentication.headers, body: {text_model: ""}.to_json).status_code.should eq 200
Model::SignageAIProvider.find!(provider.id.as(UUID)).text_model.should be_nil
end

it "lets support read, and nothing more" do
Expand Down Expand Up @@ -211,6 +220,58 @@ module PlaceOS::Api
JSON.parse(result.body)["model"].as_s.should eq "gpt-image-2.5-sunburst"
end

it "probes the text model with kind=text" do
authority = Model::Authority.find_by_domain("localhost").not_nil!
provider = Model::Generator.signage_ai_provider(
authority: authority,
name: "openai-#{random_name}",
text_model: "gpt-5.4-mini-custom",
).save!
requests = [] of JSON::Any

HttpMocks.map_ai_vendor("OK", requests: requests)
HttpMocks.signage_ai_vendor_refused

result = client.post(File.join(base, provider.id.to_s, "test?kind=text"), headers: Spec::Authentication.headers)
result.status_code.should eq 200

body = JSON.parse(result.body)
body["ok"].as_bool.should be_true
body["model"].as_s.should eq "gpt-5.4-mini-custom"

requests.size.should eq 1
requests.first["model"].as_s.should eq "gpt-5.4-mini-custom"
requests.first["max_completion_tokens"].as_i.should eq SignageAIProviders::TEXT_PROBE_TOKENS
requests.first["response_format"]?.should be_nil
requests.first["reasoning_effort"].as_s.should eq "low"

client.post(File.join(base, provider.id.to_s, "test?kind=sound"), headers: Spec::Authentication.headers).status_code.should eq 422
end

it "reports a text probe that spent its budget without answering" do
authority = Model::Authority.find_by_domain("localhost").not_nil!
provider = Model::Generator.signage_ai_provider(authority: authority, name: "openai-#{random_name}").save!

HttpMocks.map_ai_vendor("", finish: "length")

body = JSON.parse(client.post(File.join(base, provider.id.to_s, "test?kind=text"), headers: Spec::Authentication.headers).body)
body["ok"].as_bool.should be_false
body["kind"].as_s.should eq "invalid_response"
body["error"].as_s.should contain "without answering"
end

it "reports a text probe that times out" do
authority = Model::Authority.find_by_domain("localhost").not_nil!
provider = Model::Generator.signage_ai_provider(authority: authority, name: "openai-#{random_name}").save!

HttpMocks.map_ai_vendor_timeout

body = JSON.parse(client.post(File.join(base, provider.id.to_s, "test?kind=text"), headers: Spec::Authentication.headers).body)
body["ok"].as_bool.should be_false
body["kind"].as_s.should eq "timeout"
body["model"].as_s.should eq MAP_AI_DEFAULT_OPENAI_MODEL
end

it "reports a refusal as a moderation failure rather than an error" do
authority = Model::Authority.find_by_domain("localhost").not_nil!
provider = Model::Generator.signage_ai_provider(
Expand Down
60 changes: 20 additions & 40 deletions spec/controllers/signage/signage_ai_spec.cr
Original file line number Diff line number Diff line change
@@ -1,45 +1,6 @@
require "../../helper"

module PlaceOS::Api
# Everything a signage AI request needs: somewhere to put the image, and a
# vendor to ask for one. Returns (authority, storage, provider).
def self.setup_signage_ai(quotas : Hash(String, JSON::Any) = {} of String => JSON::Any)
authority = Model::Authority.find_by_domain("localhost").not_nil!
storage = Model::Generator.storage(authority_id: authority.id.as(String)).save!
provider = Model::Generator.signage_ai_provider(
authority: authority,
name: "openai-#{random_name}",
is_default: true,
quotas: quotas,
).save!

{authority, storage, provider}
end

# A signage group the caller may act in, hung off a root group.
def self.signage_group(authority, user, permissions : Model::Permissions)
root = Model::Generator.group(authority: authority).save!
group = Model::Generator.group(authority: authority, parent: root, subsystems: ["signage"]).save!
Model::Generator.group_user(user: user, group: group, permissions: permissions).save!
group
end

# Drive the long poll at `wait=0` until the job stops moving. The runner is a
# fiber in this same process, so each poll gives it a turn.
def self.await_signage_ai_job(base : String, id : String, headers : HTTP::Headers) : JSON::Any
path = File.join(base, "jobs", id)

100.times do
response = client.get(path, headers: headers)
response.status_code.should eq 200
body = JSON.parse(response.body)
return body if {"done", "failed", "cancelled"}.includes?(body["state"].as_s)
sleep 100.milliseconds
end

raise "signage AI job #{id} never reached a final state"
end

describe SignageAI do
base = SignageAI.base_route

Expand Down Expand Up @@ -77,8 +38,9 @@ module PlaceOS::Api
_, _, provider = setup_signage_ai(quotas: {"user_per_day" => JSON::Any.new(5_i64)})
user, headers = Spec::Authentication.authentication

# two candidates already spent today
# two candidates already spent today; map edits are a separate allowance
Model::Generator.signage_ai_job(user: user, provider: provider, candidates: 2).save!
Model::Generator.signage_ai_job(user: user, provider: provider, candidates: 1, kind: Model::SignageAIJob::Kind::MapEdit).save!

result = client.get(File.join(base, "capabilities"), headers: headers)
result.status_code.should eq 200
Expand Down Expand Up @@ -298,6 +260,24 @@ module PlaceOS::Api
JSON.parse(result.body)["kind"].as_s.should eq "quota"
end

it "does not count map edit jobs against the image allowance" do
_, _, provider = setup_signage_ai(quotas: {"user_per_day" => JSON::Any.new(1_i64)})
user, headers = Spec::Authentication.authentication
Model::Generator.signage_ai_job(user: user, provider: provider, candidates: 1, kind: Model::SignageAIJob::Kind::MapEdit).save!

HttpMocks.signage_ai_vendor
HttpMocks.signage_ai_storage

result = client.post(
File.join(base, "generate"),
headers: headers,
body: {prompt: "a poster", candidates: 1}.to_json,
)

result.status_code.should eq 202
await_signage_ai_job(base, JSON.parse(result.body)["id"].as_s, headers)["state"].as_s.should eq "done"
end

it "records a vendor refusal against the job as a moderation failure" do
setup_signage_ai
_, headers = Spec::Authentication.authentication
Expand Down
Loading
Loading