diff --git a/OPENAPI_DOC.yml b/OPENAPI_DOC.yml
index 74e915a0..7a2ae2ff 100644
--- a/OPENAPI_DOC.yml
+++ b/OPENAPI_DOC.yml
@@ -20686,6 +20686,838 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/PlaceOS.Api.Application.ContentError'
+ /api/engine/v2/maps/ai/capabilities:
+ summary: AI editing of a level's SVG map, with the signage AI provider rows.
+ description: 'AI editing of a level''s SVG map, with the signage AI provider rows.
+
+
+ The editor posts the instruction and the context it built from the map (the
+
+ element index, the shortlisted subtrees, the annotations and a marked up
+
+ snapshot by upload id). The answer is a list of operations the editor
+
+ applies, reviews and saves itself; nothing here reads or writes the map.
+
+ One job is one vendor call, long polled through `jobs/:id` as signage is.'
+ get:
+ summary: what this domain can do, and what the caller has left of their quota
+ tags:
+ - MapAI
+ operationId: PlaceOS::Api::MapAI_capabilities
+ parameters: []
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.MapAI.Capabilities'
+ "409":
+ description: Conflict
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "401":
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "503":
+ description: Service Unavailable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "403":
+ description: Forbidden
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "404":
+ description: Not Found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "429":
+ description: Too Many Requests
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "422":
+ description: Unprocessable Entity
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError'
+ "502":
+ description: Bad Gateway
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "504":
+ description: Gateway Timeout
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "406":
+ description: Not Acceptable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError'
+ "408":
+ description: Request Timeout
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "400":
+ description: Bad Request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError'
+ "415":
+ description: Unsupported Media Type
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError'
+ /api/engine/v2/maps/ai/edit:
+ summary: AI editing of a level's SVG map, with the signage AI provider rows.
+ description: 'AI editing of a level''s SVG map, with the signage AI provider rows.
+
+
+ The editor posts the instruction and the context it built from the map (the
+
+ element index, the shortlisted subtrees, the annotations and a marked up
+
+ snapshot by upload id). The answer is a list of operations the editor
+
+ applies, reviews and saves itself; nothing here reads or writes the map.
+
+ One job is one vendor call, long polled through `jobs/:id` as signage is.'
+ post:
+ summary: ask for operations that carry out an instruction on a level's map
+ tags:
+ - MapAI
+ operationId: PlaceOS::Api::MapAI_edit
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.MapAI.EditParams'
+ required: true
+ parameters: []
+ responses:
+ "202":
+ description: Accepted
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.MapAI.JobResponse'
+ "409":
+ description: Conflict
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "401":
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "503":
+ description: Service Unavailable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "403":
+ description: Forbidden
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "404":
+ description: Not Found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "429":
+ description: Too Many Requests
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "422":
+ description: Unprocessable Entity
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError'
+ "502":
+ description: Bad Gateway
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "504":
+ description: Gateway Timeout
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "406":
+ description: Not Acceptable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError'
+ "408":
+ description: Request Timeout
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "400":
+ description: Bad Request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError'
+ "415":
+ description: Unsupported Media Type
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError'
+ /api/engine/v2/maps/ai/jobs/{id}:
+ summary: AI editing of a level's SVG map, with the signage AI provider rows.
+ description: 'AI editing of a level''s SVG map, with the signage AI provider rows.
+
+
+ The editor posts the instruction and the context it built from the map (the
+
+ element index, the shortlisted subtrees, the annotations and a marked up
+
+ snapshot by upload id). The answer is a list of operations the editor
+
+ applies, reviews and saves itself; nothing here reads or writes the map.
+
+ One job is one vendor call, long polled through `jobs/:id` as signage is.'
+ get:
+ summary: 'long poll: holds until the job changes past `since`, or `wait` runs
+ out'
+ tags:
+ - MapAI
+ operationId: PlaceOS::Api::MapAI_show_job
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - name: wait
+ in: query
+ description: seconds to hold the request open, 0 to answer immediately
+ example: 25
+ schema:
+ type: integer
+ format: Int32
+ - name: since
+ in: query
+ description: the version the caller already has
+ example: 3
+ schema:
+ anyOf:
+ - type: integer
+ format: Int32
+ - type: "null"
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.MapAI.JobResponse'
+ "409":
+ description: Conflict
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "401":
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "503":
+ description: Service Unavailable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "403":
+ description: Forbidden
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "404":
+ description: Not Found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "429":
+ description: Too Many Requests
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "422":
+ description: Unprocessable Entity
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError'
+ "502":
+ description: Bad Gateway
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "504":
+ description: Gateway Timeout
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "406":
+ description: Not Acceptable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError'
+ "408":
+ description: Request Timeout
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "400":
+ description: Bad Request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError'
+ "415":
+ description: Unsupported Media Type
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError'
+ /api/engine/v2/maps/ai/jobs:
+ summary: AI editing of a level's SVG map, with the signage AI provider rows.
+ description: 'AI editing of a level''s SVG map, with the signage AI provider rows.
+
+
+ The editor posts the instruction and the context it built from the map (the
+
+ element index, the shortlisted subtrees, the annotations and a marked up
+
+ snapshot by upload id). The answer is a list of operations the editor
+
+ applies, reviews and saves itself; nothing here reads or writes the map.
+
+ One job is one vendor call, long polled through `jobs/:id` as signage is.'
+ get:
+ summary: recent map edit jobs
+ tags:
+ - MapAI
+ operationId: PlaceOS::Api::MapAI_index_jobs
+ parameters:
+ - name: mine
+ in: query
+ description: only the caller's own jobs
+ example: true
+ schema:
+ type: boolean
+ - name: zone_id
+ in: query
+ description: only jobs against this zone
+ example: zone-1234
+ schema:
+ anyOf:
+ - type: "null"
+ - type: string
+ - name: kind
+ in: query
+ description: map_edit; another kind lists that kind instead
+ example: map_edit
+ schema:
+ type: string
+ - name: limit
+ in: query
+ schema:
+ type: integer
+ format: Int32
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/PlaceOS.Api.MapAI.JobResponse'
+ "409":
+ description: Conflict
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "401":
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "503":
+ description: Service Unavailable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "403":
+ description: Forbidden
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "404":
+ description: Not Found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "429":
+ description: Too Many Requests
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "422":
+ description: Unprocessable Entity
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError'
+ "502":
+ description: Bad Gateway
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "504":
+ description: Gateway Timeout
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "406":
+ description: Not Acceptable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError'
+ "408":
+ description: Request Timeout
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "400":
+ description: Bad Request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError'
+ "415":
+ description: Unsupported Media Type
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError'
+ /api/engine/v2/maps/ai/jobs/{id}/cancel:
+ summary: AI editing of a level's SVG map, with the signage AI provider rows.
+ description: 'AI editing of a level''s SVG map, with the signage AI provider rows.
+
+
+ The editor posts the instruction and the context it built from the map (the
+
+ element index, the shortlisted subtrees, the annotations and a marked up
+
+ snapshot by upload id). The answer is a list of operations the editor
+
+ applies, reviews and saves itself; nothing here reads or writes the map.
+
+ One job is one vendor call, long polled through `jobs/:id` as signage is.'
+ post:
+ summary: ask a running job to stop. A call already with the vendor runs to completion.
+ tags:
+ - MapAI
+ operationId: PlaceOS::Api::MapAI_cancel
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.MapAI.JobResponse'
+ "409":
+ description: Conflict
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "401":
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "503":
+ description: Service Unavailable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "403":
+ description: Forbidden
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "404":
+ description: Not Found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "429":
+ description: Too Many Requests
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "422":
+ description: Unprocessable Entity
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError'
+ "502":
+ description: Bad Gateway
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "504":
+ description: Gateway Timeout
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "406":
+ description: Not Acceptable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError'
+ "408":
+ description: Request Timeout
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "400":
+ description: Bad Request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError'
+ "415":
+ description: Unsupported Media Type
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError'
+ /api/engine/v2/maps/ai/jobs/{id}/resolve:
+ summary: AI editing of a level's SVG map, with the signage AI provider rows.
+ description: 'AI editing of a level''s SVG map, with the signage AI provider rows.
+
+
+ The editor posts the instruction and the context it built from the map (the
+
+ element index, the shortlisted subtrees, the annotations and a marked up
+
+ snapshot by upload id). The answer is a list of operations the editor
+
+ applies, reviews and saves itself; nothing here reads or writes the map.
+
+ One job is one vendor call, long polled through `jobs/:id` as signage is.'
+ post:
+ summary: 'record what the person did with the answer: which ops were applied
+ or'
+ description: 'record what the person did with the answer: which ops were applied
+ or
+
+ rejected and why, and the upload the saved map became'
+ tags:
+ - MapAI
+ operationId: PlaceOS::Api::MapAI_resolve
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.MapAI.ResolveParams'
+ required: true
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.MapAI.JobResponse'
+ "409":
+ description: Conflict
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "401":
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "503":
+ description: Service Unavailable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "403":
+ description: Forbidden
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "404":
+ description: Not Found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "429":
+ description: Too Many Requests
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "422":
+ description: Unprocessable Entity
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError'
+ "502":
+ description: Bad Gateway
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "504":
+ description: Gateway Timeout
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "406":
+ description: Not Acceptable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError'
+ "408":
+ description: Request Timeout
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "400":
+ description: Bad Request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError'
+ "415":
+ description: Unsupported Media Type
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError'
+ /api/engine/v2/maps/ai/usage:
+ summary: AI editing of a level's SVG map, with the signage AI provider rows.
+ description: 'AI editing of a level''s SVG map, with the signage AI provider rows.
+
+
+ The editor posts the instruction and the context it built from the map (the
+
+ element index, the shortlisted subtrees, the annotations and a marked up
+
+ snapshot by upload id). The answer is a list of operations the editor
+
+ applies, reviews and saves itself; nothing here reads or writes the map.
+
+ One job is one vendor call, long polled through `jobs/:id` as signage is.'
+ get:
+ summary: map edit spend per provider and model
+ tags:
+ - MapAI
+ operationId: PlaceOS::Api::MapAI_usage
+ parameters:
+ - name: from
+ in: query
+ description: unix seconds, defaults to 30 days ago
+ schema:
+ anyOf:
+ - type: integer
+ format: Int64
+ - type: "null"
+ - name: to
+ in: query
+ description: unix seconds, defaults to now
+ schema:
+ anyOf:
+ - type: integer
+ format: Int64
+ - type: "null"
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/PlaceOS.Model.SignageAIJob.UsageRow'
+ "409":
+ description: Conflict
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "401":
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "503":
+ description: Service Unavailable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "403":
+ description: Forbidden
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "404":
+ description: Not Found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "429":
+ description: Too Many Requests
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError'
+ "422":
+ description: Unprocessable Entity
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError'
+ "502":
+ description: Bad Gateway
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "504":
+ description: Gateway Timeout
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "406":
+ description: Not Acceptable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError'
+ "408":
+ description: Request Timeout
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError'
+ "400":
+ description: Bad Request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError'
+ "415":
+ description: Unsupported Media Type
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError'
/api/engine/v2/metadata/{name}/bulk:
summary: Metadata, named JSON documents attached to zones, systems and users (e.g.
floor maps, booking rules)
@@ -28848,6 +29680,14 @@ paths:
schema:
type: integer
format: Int32
+ - name: kind
+ in: query
+ description: generate, edit or map_edit; the image kinds when left out
+ example: generate
+ schema:
+ anyOf:
+ - type: "null"
+ - type: string
responses:
"200":
description: OK
@@ -29288,8 +30128,8 @@ paths:
schema:
$ref: '#/components/schemas/PlaceOS.Api.Application.ContentError'
/api/engine/v2/signage/ai/providers:
- summary: Vendor credentials for signage image generation.
- description: 'Vendor credentials for signage image generation.
+ summary: Vendor credentials for signage image generation and AI map editing.
+ description: 'Vendor credentials for signage image generation and AI map editing.
The Data Stores rule: sys_admin writes, support reads. Responses never carry
@@ -29362,7 +30202,7 @@ paths:
schema:
type: array
items:
- $ref: '#/components/schemas/NamedTuple-oid-u00003A-u000020String-nname-u00003A-u000020String-nprovider-u00003A-u000020String-nauthority_id-u00003A-u000020String-pNil-nendpoint-u00003A-u000020String-pNil-nlocation-u00003A-u000020String-pNil-ndefault_model-u00003A-u000020String-pNil-nallowed_models-u00003A-u000020Array-oString-c-nenabled-u00003A-u000020Bool-nis_default-u00003A-u000020Bool-nquotas-u00003A-u000020JSON.Any-ncreated_at-u00003A-u000020Int64-nupdated_at-u00003A-u000020Int64-c'
+ $ref: '#/components/schemas/NamedTuple-oid-u00003A-u000020String-nname-u00003A-u000020String-nprovider-u00003A-u000020String-nauthority_id-u00003A-u000020String-pNil-nendpoint-u00003A-u000020String-pNil-nlocation-u00003A-u000020String-pNil-ndefault_model-u00003A-u000020String-pNil-ntext_model-u00003A-u000020String-pNil-nallowed_models-u00003A-u000020Array-oString-c-nenabled-u00003A-u000020Bool-nis_default-u00003A-u000020Bool-nquotas-u00003A-u000020JSON.Any-ncreated_at-u00003A-u000020Int64-nupdated_at-u00003A-u000020Int64-c'
"409":
description: Conflict
content:
@@ -29458,7 +30298,7 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/NamedTuple-oid-u00003A-u000020String-nname-u00003A-u000020String-nprovider-u00003A-u000020String-nauthority_id-u00003A-u000020String-pNil-nendpoint-u00003A-u000020String-pNil-nlocation-u00003A-u000020String-pNil-ndefault_model-u00003A-u000020String-pNil-nallowed_models-u00003A-u000020Array-oString-c-nenabled-u00003A-u000020Bool-nis_default-u00003A-u000020Bool-nquotas-u00003A-u000020JSON.Any-ncreated_at-u00003A-u000020Int64-nupdated_at-u00003A-u000020Int64-c'
+ $ref: '#/components/schemas/NamedTuple-oid-u00003A-u000020String-nname-u00003A-u000020String-nprovider-u00003A-u000020String-nauthority_id-u00003A-u000020String-pNil-nendpoint-u00003A-u000020String-pNil-nlocation-u00003A-u000020String-pNil-ndefault_model-u00003A-u000020String-pNil-ntext_model-u00003A-u000020String-pNil-nallowed_models-u00003A-u000020Array-oString-c-nenabled-u00003A-u000020Bool-nis_default-u00003A-u000020Bool-nquotas-u00003A-u000020JSON.Any-ncreated_at-u00003A-u000020Int64-nupdated_at-u00003A-u000020Int64-c'
"409":
description: Conflict
content:
@@ -29538,8 +30378,8 @@ paths:
schema:
$ref: '#/components/schemas/PlaceOS.Api.Application.ContentError'
/api/engine/v2/signage/ai/providers/{id}:
- summary: Vendor credentials for signage image generation.
- description: 'Vendor credentials for signage image generation.
+ summary: Vendor credentials for signage image generation and AI map editing.
+ description: 'Vendor credentials for signage image generation and AI map editing.
The Data Stores rule: sys_admin writes, support reads. Responses never carry
@@ -29562,7 +30402,7 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/NamedTuple-oid-u00003A-u000020String-nname-u00003A-u000020String-nprovider-u00003A-u000020String-nauthority_id-u00003A-u000020String-pNil-nendpoint-u00003A-u000020String-pNil-nlocation-u00003A-u000020String-pNil-ndefault_model-u00003A-u000020String-pNil-nallowed_models-u00003A-u000020Array-oString-c-nenabled-u00003A-u000020Bool-nis_default-u00003A-u000020Bool-nquotas-u00003A-u000020JSON.Any-ncreated_at-u00003A-u000020Int64-nupdated_at-u00003A-u000020Int64-c'
+ $ref: '#/components/schemas/NamedTuple-oid-u00003A-u000020String-nname-u00003A-u000020String-nprovider-u00003A-u000020String-nauthority_id-u00003A-u000020String-pNil-nendpoint-u00003A-u000020String-pNil-nlocation-u00003A-u000020String-pNil-ndefault_model-u00003A-u000020String-pNil-ntext_model-u00003A-u000020String-pNil-nallowed_models-u00003A-u000020Array-oString-c-nenabled-u00003A-u000020Bool-nis_default-u00003A-u000020Bool-nquotas-u00003A-u000020JSON.Any-ncreated_at-u00003A-u000020Int64-nupdated_at-u00003A-u000020Int64-c'
"409":
description: Conflict
content:
@@ -29664,7 +30504,7 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/NamedTuple-oid-u00003A-u000020String-nname-u00003A-u000020String-nprovider-u00003A-u000020String-nauthority_id-u00003A-u000020String-pNil-nendpoint-u00003A-u000020String-pNil-nlocation-u00003A-u000020String-pNil-ndefault_model-u00003A-u000020String-pNil-nallowed_models-u00003A-u000020Array-oString-c-nenabled-u00003A-u000020Bool-nis_default-u00003A-u000020Bool-nquotas-u00003A-u000020JSON.Any-ncreated_at-u00003A-u000020Int64-nupdated_at-u00003A-u000020Int64-c'
+ $ref: '#/components/schemas/NamedTuple-oid-u00003A-u000020String-nname-u00003A-u000020String-nprovider-u00003A-u000020String-nauthority_id-u00003A-u000020String-pNil-nendpoint-u00003A-u000020String-pNil-nlocation-u00003A-u000020String-pNil-ndefault_model-u00003A-u000020String-pNil-ntext_model-u00003A-u000020String-pNil-nallowed_models-u00003A-u000020Array-oString-c-nenabled-u00003A-u000020Bool-nis_default-u00003A-u000020Bool-nquotas-u00003A-u000020JSON.Any-ncreated_at-u00003A-u000020Int64-nupdated_at-u00003A-u000020Int64-c'
"409":
description: Conflict
content:
@@ -29858,7 +30698,7 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/NamedTuple-oid-u00003A-u000020String-nname-u00003A-u000020String-nprovider-u00003A-u000020String-nauthority_id-u00003A-u000020String-pNil-nendpoint-u00003A-u000020String-pNil-nlocation-u00003A-u000020String-pNil-ndefault_model-u00003A-u000020String-pNil-nallowed_models-u00003A-u000020Array-oString-c-nenabled-u00003A-u000020Bool-nis_default-u00003A-u000020Bool-nquotas-u00003A-u000020JSON.Any-ncreated_at-u00003A-u000020Int64-nupdated_at-u00003A-u000020Int64-c'
+ $ref: '#/components/schemas/NamedTuple-oid-u00003A-u000020String-nname-u00003A-u000020String-nprovider-u00003A-u000020String-nauthority_id-u00003A-u000020String-pNil-nendpoint-u00003A-u000020String-pNil-nlocation-u00003A-u000020String-pNil-ndefault_model-u00003A-u000020String-pNil-ntext_model-u00003A-u000020String-pNil-nallowed_models-u00003A-u000020Array-oString-c-nenabled-u00003A-u000020Bool-nis_default-u00003A-u000020Bool-nquotas-u00003A-u000020JSON.Any-ncreated_at-u00003A-u000020Int64-nupdated_at-u00003A-u000020Int64-c'
"409":
description: Conflict
content:
@@ -29938,8 +30778,8 @@ paths:
schema:
$ref: '#/components/schemas/PlaceOS.Api.Application.ContentError'
/api/engine/v2/signage/ai/providers/{id}/test:
- summary: Vendor credentials for signage image generation.
- description: 'Vendor credentials for signage image generation.
+ summary: Vendor credentials for signage image generation and AI map editing.
+ description: 'Vendor credentials for signage image generation and AI map editing.
The Data Stores rule: sys_admin writes, support reads. Responses never carry
@@ -29951,7 +30791,9 @@ paths:
description: 'Prove a row''s credentials work, without leaving anything behind:
one small
- image, discarded.'
+ image, discarded, or with `kind=text` one tiny chat completion against the
+
+ row''s text model.'
tags:
- SignageAIProviders
operationId: PlaceOS::Api::SignageAIProviders_test
@@ -29961,7 +30803,13 @@ paths:
required: true
schema:
type: string
- format: uuid
+ format: uuid
+ - name: kind
+ in: query
+ description: image (the default) or text
+ example: text
+ schema:
+ type: string
responses:
"200":
description: OK
@@ -45641,6 +46489,252 @@ components:
anyOf:
- type: "null"
- $ref: '#/components/schemas/PlaceOS.Model.Zone'
+ PlaceOS.Api.MapAI.Capabilities:
+ type: object
+ properties:
+ enabled:
+ type: boolean
+ reason:
+ anyOf:
+ - type: "null"
+ - type: string
+ providers:
+ type: array
+ items:
+ $ref: '#/components/schemas/PlaceOS.Api.Llm.ProviderCapabilities'
+ default_provider_id:
+ anyOf:
+ - type: "null"
+ - type: string
+ quota:
+ type: object
+ properties:
+ user_remaining_today:
+ anyOf:
+ - type: integer
+ format: Int32
+ - type: "null"
+ domain_remaining_month:
+ anyOf:
+ - type: integer
+ format: Int32
+ - type: "null"
+ limits:
+ type: object
+ properties:
+ max_context_bytes:
+ type: integer
+ format: Int32
+ max_ops:
+ type: integer
+ format: Int32
+ max_instruction_chars:
+ type: integer
+ format: Int32
+ required:
+ - max_context_bytes
+ - max_ops
+ - max_instruction_chars
+ required:
+ - enabled
+ - providers
+ - quota
+ - limits
+ PlaceOS.Api.MapAI.EditParams:
+ type: object
+ properties:
+ zone_id:
+ type: string
+ instruction:
+ type: string
+ context:
+ anyOf:
+ - type: object
+ - type: "null"
+ markup_upload_id:
+ anyOf:
+ - type: "null"
+ - type: string
+ parent_job_id:
+ anyOf:
+ - type: string
+ format: uuid
+ - type: "null"
+ provider_id:
+ anyOf:
+ - type: string
+ format: uuid
+ - type: "null"
+ model:
+ anyOf:
+ - type: "null"
+ - type: string
+ idempotency_key:
+ anyOf:
+ - type: "null"
+ - type: string
+ required:
+ - zone_id
+ - instruction
+ PlaceOS.Api.MapAI.JobResponse:
+ type: object
+ properties:
+ id:
+ type: string
+ state:
+ type: string
+ kind:
+ type: string
+ zone_id:
+ anyOf:
+ - type: "null"
+ - type: string
+ provider:
+ anyOf:
+ - type: "null"
+ - type: string
+ model:
+ anyOf:
+ - type: "null"
+ - type: string
+ parent_job_id:
+ anyOf:
+ - type: "null"
+ - type: string
+ version:
+ type: integer
+ format: Int32
+ instruction:
+ anyOf:
+ - type: "null"
+ - type: string
+ markup_upload_id:
+ anyOf:
+ - type: "null"
+ - type: string
+ schema_version:
+ anyOf:
+ - type: integer
+ format: Int64
+ - type: "null"
+ summary:
+ anyOf:
+ - type: "null"
+ - type: string
+ ops:
+ type: array
+ items:
+ type: object
+ server_issues:
+ type: object
+ additionalProperties:
+ type: string
+ questions:
+ type: array
+ items:
+ type: object
+ usage:
+ anyOf:
+ - type: object
+ - type: "null"
+ resolution:
+ anyOf:
+ - type: object
+ - type: "null"
+ error_kind:
+ anyOf:
+ - type: "null"
+ - type: string
+ error_message:
+ anyOf:
+ - type: "null"
+ - type: string
+ cost_units:
+ anyOf:
+ - type: number
+ format: Float64
+ - type: "null"
+ latency_ms:
+ anyOf:
+ - type: integer
+ format: Int64
+ - type: "null"
+ created_at:
+ anyOf:
+ - type: integer
+ format: Int64
+ - type: "null"
+ finished_at:
+ anyOf:
+ - type: integer
+ format: Int64
+ - type: "null"
+ required:
+ - id
+ - state
+ - kind
+ - version
+ - ops
+ - server_issues
+ - questions
+ description: 'A job as the editor sees it. `ops` are the model''s operations
+ exactly as
+
+ returned; `server_issues` names the ones the server could not accept.'
+ PlaceOS.Api.MapAI.ResolveParams:
+ type: object
+ properties:
+ decision:
+ type: string
+ accepted_op_ids:
+ type: array
+ items:
+ type: string
+ rejected_op_ids:
+ type: array
+ items:
+ type: string
+ reasons:
+ anyOf:
+ - type: object
+ - type: "null"
+ saved_upload_id:
+ anyOf:
+ - type: "null"
+ - type: string
+ required:
+ - decision
+ - accepted_op_ids
+ - rejected_op_ids
+ PlaceOS.Model.SignageAIJob.UsageRow:
+ type: object
+ properties:
+ kind:
+ type: string
+ provider:
+ type: string
+ model:
+ type: string
+ jobs:
+ type: integer
+ format: Int64
+ candidates:
+ type: integer
+ format: Int64
+ images_produced:
+ type: integer
+ format: Int64
+ cost_units:
+ type: number
+ format: Float64
+ required:
+ - kind
+ - provider
+ - model
+ - jobs
+ - candidates
+ - images_produced
+ - cost_units
PlaceOS.Api.Metadata.Children:
type: object
properties:
@@ -46388,32 +47482,6 @@ components:
required:
- upload_id
- item_id
- PlaceOS.Model.SignageAIJob.UsageRow:
- type: object
- properties:
- provider:
- type: string
- model:
- type: string
- jobs:
- type: integer
- format: Int64
- candidates:
- type: integer
- format: Int64
- images_produced:
- type: integer
- format: Int64
- cost_units:
- type: number
- format: Float64
- required:
- - provider
- - model
- - jobs
- - candidates
- - images_produced
- - cost_units
PlaceOS.Api.SignageAIProviders.ProviderParams:
type: object
properties:
@@ -46445,6 +47513,10 @@ components:
anyOf:
- type: "null"
- type: string
+ text_model:
+ anyOf:
+ - type: "null"
+ - type: string
allowed_models:
anyOf:
- type: "null"
@@ -48247,10 +49319,10 @@ components:
required:
- error
- kind
- description: 'Signage AI image generation. `kind` lets a client branch without
- matching
+ description: 'Signage AI image generation and map AI editing. `kind` lets a
+ client
- on message text.'
+ branch without matching on message text.'
PlaceOS.Api.Application.ValidationError:
type: object
properties:
@@ -48559,6 +49631,27 @@ components:
- landscape
- portrait
- square
+ PlaceOS.Api.Llm.ProviderCapabilities:
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ provider:
+ type: string
+ text_model:
+ type: string
+ text_models:
+ type: array
+ items:
+ type: string
+ required:
+ - id
+ - name
+ - provider
+ - text_model
+ - text_models
PlaceOS.Api.MicrosoftNotification.ChangeNotification:
type: object
properties:
@@ -49328,7 +50421,7 @@ components:
type: string
required:
- commit_hash
- ? NamedTuple-oid-u00003A-u000020String-nname-u00003A-u000020String-nprovider-u00003A-u000020String-nauthority_id-u00003A-u000020String-pNil-nendpoint-u00003A-u000020String-pNil-nlocation-u00003A-u000020String-pNil-ndefault_model-u00003A-u000020String-pNil-nallowed_models-u00003A-u000020Array-oString-c-nenabled-u00003A-u000020Bool-nis_default-u00003A-u000020Bool-nquotas-u00003A-u000020JSON.Any-ncreated_at-u00003A-u000020Int64-nupdated_at-u00003A-u000020Int64-c
+ ? NamedTuple-oid-u00003A-u000020String-nname-u00003A-u000020String-nprovider-u00003A-u000020String-nauthority_id-u00003A-u000020String-pNil-nendpoint-u00003A-u000020String-pNil-nlocation-u00003A-u000020String-pNil-ndefault_model-u00003A-u000020String-pNil-ntext_model-u00003A-u000020String-pNil-nallowed_models-u00003A-u000020Array-oString-c-nenabled-u00003A-u000020Bool-nis_default-u00003A-u000020Bool-nquotas-u00003A-u000020JSON.Any-ncreated_at-u00003A-u000020Int64-nupdated_at-u00003A-u000020Int64-c
: type: object
properties:
id:
@@ -49353,6 +50446,10 @@ components:
anyOf:
- type: "null"
- type: string
+ text_model:
+ anyOf:
+ - type: "null"
+ - type: string
allowed_models:
type: array
items:
diff --git a/shard.lock b/shard.lock
index 73b93118..d9072e23 100644
--- a/shard.lock
+++ b/shard.lock
@@ -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
diff --git a/shard.yml b/shard.yml
index a04901c0..e309c2e8 100644
--- a/shard.yml
+++ b/shard.yml
@@ -58,7 +58,7 @@ dependencies:
# Database view
placeos-models:
github: placeos/models
- version: ">= 9.5"
+ version: ">= 9.120"
# An abstraction over Fibers
promise:
diff --git a/spec/controllers/map_ai_spec.cr b/spec/controllers/map_ai_spec.cr
new file mode 100644
index 00000000..e0ab0270
--- /dev/null
+++ b/spec/controllers/map_ai_spec.cr
@@ -0,0 +1,897 @@
+require "../helper"
+
+module PlaceOS::Api
+ # A level zone under the org zone the auth helper points every authority at,
+ # which is what the legacy ACL (concierge: manage) and the AI gate look at.
+ def self.map_ai_level : Model::Zone
+ zone = Model::Generator.zone
+ zone.parent_id = Spec::Authentication.org_zone.id.as(String)
+ zone.tags = Set{"level"}
+ zone.map_id = "/api/engine/v2/uploads/upload-spec/url"
+ zone.save!
+ end
+
+ def self.map_ai_context : JSON::Any
+ JSON.parse(File.read(File.join(__DIR__, "../fixtures/map_ai/context.json")))
+ end
+
+ # the model's answer for the fixture context: one grounded recolour
+ MAP_AI_ANSWER = {
+ summary: "Recoloured Meeting 1.01 light green.",
+ ops: [
+ {
+ op: "set_attr",
+ why: "Meeting 1.01 is the room the instruction names.",
+ target: {a: "e2", id: "area-1.01-status", h: "b2b2b2b2"},
+ name: "fill",
+ value: "#90ee90",
+ },
+ ],
+ questions: [] of String,
+ }.to_json
+
+ describe MapAI do
+ base = MapAI.base_route
+
+ before_each do
+ Model::SignageAIJob.clear
+ Model::SignageAIProvider.clear
+ Model::Upload.clear
+ Model::Storage.clear
+ clear_group_tables
+
+ WebMock.allow_net_connect = false
+ end
+
+ describe "capabilities" do
+ it "is off, with a reason, when the domain has no provider" do
+ result = client.get(File.join(base, "capabilities"), headers: Spec::Authentication.headers)
+ result.status_code.should eq 200
+
+ body = JSON.parse(result.body)
+ body["enabled"].as_bool.should be_false
+ body["reason"].as_s.should eq "no AI provider is configured for this domain"
+ body["providers"].as_a.should be_empty
+ body["limits"]["max_context_bytes"].as_i.should eq MAP_AI_MAX_CONTEXT_BYTES
+ end
+
+ it "is off when the only provider is switched off" do
+ authority = Model::Authority.find_by_domain("localhost").not_nil!
+ Model::Generator.signage_ai_provider(authority: authority, name: "off-#{random_name}", enabled: false).save!
+
+ body = JSON.parse(client.get(File.join(base, "capabilities"), headers: Spec::Authentication.headers).body)
+ body["enabled"].as_bool.should be_false
+ end
+
+ it "is off when the only provider cannot edit maps yet" do
+ authority = Model::Authority.find_by_domain("localhost").not_nil!
+ Model::Generator.signage_ai_provider(
+ authority: authority,
+ name: "vertex-#{random_name}",
+ provider: Model::SignageAIProvider::Provider::GOOGLE_VERTEX,
+ credentials: %({"project_id":"p","client_email":"e","private_key":"k"}),
+ ).save!
+
+ body = JSON.parse(client.get(File.join(base, "capabilities"), headers: Spec::Authentication.headers).body)
+ body["enabled"].as_bool.should be_false
+ body["reason"].as_s.should eq "the configured AI provider cannot edit maps yet"
+ end
+
+ it "is on, with the text model and what is left of the map quota" do
+ _, _, provider = setup_signage_ai(quotas: {"map_user_per_day" => JSON::Any.new(5_i64), "user_per_day" => JSON::Any.new(1_i64)})
+ provider.text_model = "gpt-5.4-mini-custom"
+ provider.save!
+ user, headers = Spec::Authentication.authentication
+
+ # image jobs do not count against the map quota, map jobs do
+ 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
+
+ body = JSON.parse(result.body)
+ body["enabled"].as_bool.should be_true
+ body["default_provider_id"].as_s.should eq provider.id.to_s
+ body["quota"]["user_remaining_today"].as_i.should eq 4
+ body["limits"]["max_ops"].as_i.should eq MAP_AI_MAX_OPS
+
+ providers = body["providers"].as_a
+ providers.size.should eq 1
+ providers.first["provider"].as_s.should eq "OPENAI"
+ providers.first["text_model"].as_s.should eq "gpt-5.4-mini-custom"
+ end
+
+ it "falls back to the default text model" do
+ setup_signage_ai
+ body = JSON.parse(client.get(File.join(base, "capabilities"), headers: Spec::Authentication.headers).body)
+ body["providers"].as_a.first["text_model"].as_s.should eq MAP_AI_DEFAULT_OPENAI_MODEL
+ end
+
+ it "lists the text models each provider can run, the row's own first" do
+ authority, _, provider = setup_signage_ai
+ provider.text_model = "gpt-5.4-mini-custom"
+ provider.save!
+ Model::Generator.signage_ai_provider(
+ authority: authority,
+ name: "claude-#{random_name}",
+ provider: Model::SignageAIProvider::Provider::ANTHROPIC,
+ ).save!
+
+ body = JSON.parse(client.get(File.join(base, "capabilities"), headers: Spec::Authentication.headers).body)
+ body["enabled"].as_bool.should be_true
+ body["default_provider_id"].as_s.should eq provider.id.to_s
+ providers = body["providers"].as_a
+ providers.size.should eq 2
+
+ openai = providers.find! { |entry| entry["provider"].as_s == "OPENAI" }
+ openai["text_model"].as_s.should eq "gpt-5.4-mini-custom"
+ openai["text_models"].as_a.map(&.as_s).should eq ["gpt-5.4-mini-custom"] + MAP_AI_OPENAI_MODELS
+
+ claude = providers.find! { |entry| entry["provider"].as_s == "ANTHROPIC" }
+ claude["text_model"].as_s.should eq MAP_AI_DEFAULT_ANTHROPIC_MODEL
+ claude["text_models"].as_a.map(&.as_s).should eq MAP_AI_ANTHROPIC_MODELS
+ claude["text_models"].as_a.map(&.as_s).should contain "claude-fable-5-1"
+ end
+ end
+
+ describe "edit" do
+ it "accepts the request, records the context, and finishes the job with the ops" do
+ _, _, provider = setup_signage_ai
+ user, headers = Spec::Authentication.authentication
+ level = map_ai_level
+ requests = [] of JSON::Any
+
+ HttpMocks.map_ai_vendor(MAP_AI_ANSWER, requests: requests)
+ HttpMocks.signage_ai_vendor
+
+ context = map_ai_context
+ result = client.post(
+ File.join(base, "edit"),
+ headers: headers,
+ body: {zone_id: level.id, instruction: "Make meeting room 1.01 light green.", context: context}.to_json,
+ )
+
+ result.status_code.should eq 202
+ accepted = JSON.parse(result.body)
+ accepted["state"].as_s.should eq "queued"
+ accepted["kind"].as_s.should eq "map_edit"
+ accepted["zone_id"].as_s.should eq level.id
+ accepted["provider"].as_s.should eq "OPENAI"
+ accepted["model"].as_s.should eq MAP_AI_DEFAULT_OPENAI_MODEL
+ accepted["instruction"].as_s.should eq "Make meeting room 1.01 light green."
+ accepted["ops"].as_a.should be_empty
+
+ job_id = accepted["id"].as_s
+ stored = Model::SignageAIJob.find!(UUID.new(job_id))
+ stored.kind.should eq Model::SignageAIJob::Kind::MapEdit
+ stored.candidates.should eq 1
+ stored.provider_id.should eq provider.id
+ stored.user_id.should eq user.id
+ stored.request["context_bytes"].as_i.should eq context.to_json.bytesize
+ stored.request["context_sha256"].as_s.size.should eq 64
+ stored.request["element_count"].as_i.should eq 7
+ # the context body itself is not on the row
+ stored.request.to_json.should_not contain "table-01.004"
+
+ final = await_signage_ai_job(base, job_id, headers)
+ Llm.slots.available.should eq MAP_AI_MAX_CALLS
+ final["state"].as_s.should eq "done"
+ final["summary"].as_s.should eq "Recoloured Meeting 1.01 light green."
+ final["schema_version"].as_i.should eq 1
+ final["questions"].as_a.should be_empty
+ final["usage"]["total_tokens"].as_i.should eq 1280
+ final["usage"]["model"].as_s.should eq MAP_AI_DEFAULT_OPENAI_MODEL
+ final["cost_units"].as_f.should eq 1280.0
+ final["error_kind"]?.should be_nil
+
+ ops = final["ops"].as_a
+ ops.size.should eq 1
+ # the model's op with nothing added (jsonb orders the keys); the editor
+ # validates it against the same schema
+ ops.first.as_h.keys.sort!.should eq ["name", "op", "target", "value", "why"]
+ ops.first["target"]["a"].as_s.should eq "e2"
+ ops.first["value"].as_s.should eq "#90ee90"
+ final["server_issues"].as_h.should be_empty
+ final["usage"]["attempts"].as_i.should eq 1
+
+ # what reached the vendor
+ requests.size.should eq 1
+ sent = requests.first
+ sent["model"].as_s.should eq MAP_AI_DEFAULT_OPENAI_MODEL
+ sent["max_completion_tokens"].as_i.should eq MAP_AI_MAX_OUTPUT_TOKENS
+ sent["reasoning_effort"].as_s.should eq "low"
+ sent["response_format"]["type"].as_s.should eq "json_schema"
+ sent["response_format"]["json_schema"]["strict"].as_bool.should be_true
+ sent["response_format"]["json_schema"]["name"].as_s.should eq "map_edit"
+ sent["response_format"]["json_schema"]["schema"].should eq Llm::Schema.openai
+ messages = sent["messages"].as_a
+ messages.first["role"].as_s.should eq "system"
+ messages.first["content"].as_s.should contain "You edit SVG floor-plan maps"
+ user_parts = messages.last["content"].as_a
+ user_parts.first["type"].as_s.should eq "text"
+ user_parts.first["text"].as_s.should contain "e2 | area-1.01-status"
+ user_parts.last["text"].as_s.should end_with "INSTRUCTION\nMake meeting room 1.01 light green."
+ sent["temperature"]?.should be_nil
+
+ Model::SignageAIJob.sum_candidates(user.id.as(String), 1.day.ago, Model::SignageAIJob::Kind::MapEdit).should eq 1
+ end
+
+ it "sends the markup snapshot as an image part and tags it for the sweep" do
+ _, storage, _ = setup_signage_ai
+ user, headers = Spec::Authentication.authentication
+ level = map_ai_level
+ requests = [] of JSON::Any
+
+ snapshot = Model::Generator.upload(uploader: user, storage_id: storage.id)
+ snapshot.file_name = "snapshot.png"
+ snapshot.save!
+
+ HttpMocks.map_ai_vendor(MAP_AI_ANSWER, requests: requests)
+ HttpMocks.signage_ai_storage
+
+ result = client.post(
+ File.join(base, "edit"),
+ headers: headers,
+ body: {zone_id: level.id, instruction: "Recolour the room under pin 2.", context: map_ai_context, markup_upload_id: snapshot.id}.to_json,
+ )
+ result.status_code.should eq 202
+ job_id = JSON.parse(result.body)["id"].as_s
+ JSON.parse(result.body)["markup_upload_id"].as_s.should eq snapshot.id
+
+ Model::Upload.find!(snapshot.id.as(String)).tags.should eq [ImageGen::Store::MARKUP_TAG]
+ Model::SignageAIJob.find!(UUID.new(job_id)).upload_ids.should eq [snapshot.id]
+
+ final = await_signage_ai_job(base, job_id, headers)
+ final["state"].as_s.should eq "done"
+
+ parts = requests.first["messages"].as_a.last["content"].as_a
+ image = parts.find! { |part| part["type"].as_s == "image_url" }
+ image["image_url"]["url"].as_s.should start_with "data:image/jpeg;base64,"
+ image["image_url"]["detail"].as_s.should eq "high"
+ # subtrees before the image, annotations after it
+ parts.index!(image).should be > parts.index! { |part| part["type"].as_s == "text" && part["text"].as_s.starts_with?("SUBTREES") }
+ parts.index!(image).should be < parts.index! { |part| part["type"].as_s == "text" && part["text"].as_s.starts_with?("ANNOTATIONS") }
+ end
+
+ it "refuses a snapshot the caller does not own" do
+ authority, storage, _ = setup_signage_ai
+ _, headers = Spec::Authentication.authentication
+ level = map_ai_level
+
+ owner = Model::Generator.user(authority: authority).save!
+ snapshot = Model::Generator.upload(uploader: owner, storage_id: storage.id)
+ snapshot.file_name = "snapshot.png"
+ snapshot.save!
+
+ result = client.post(
+ File.join(base, "edit"),
+ headers: headers,
+ body: {zone_id: level.id, instruction: "x", context: map_ai_context, markup_upload_id: snapshot.id}.to_json,
+ )
+ result.status_code.should eq 403
+ JSON.parse(result.body)["kind"].as_s.should eq "permission"
+ end
+
+ it "accepts fenced JSON" do
+ setup_signage_ai
+ _, headers = Spec::Authentication.authentication
+ level = map_ai_level
+
+ HttpMocks.map_ai_vendor("```json\n#{MAP_AI_ANSWER}\n```")
+
+ result = client.post(File.join(base, "edit"), headers: headers, body: {zone_id: level.id, instruction: "x", context: map_ai_context}.to_json)
+ result.status_code.should eq 202
+
+ final = await_signage_ai_job(base, JSON.parse(result.body)["id"].as_s, headers)
+ final["state"].as_s.should eq "done"
+ final["ops"].as_a.size.should eq 1
+ end
+
+ it "fails the job as invalid_response when the model answers in prose" do
+ setup_signage_ai
+ _, headers = Spec::Authentication.authentication
+ level = map_ai_level
+
+ HttpMocks.map_ai_vendor_garbage
+
+ result = client.post(File.join(base, "edit"), headers: headers, body: {zone_id: level.id, instruction: "x", context: map_ai_context}.to_json)
+ result.status_code.should eq 202
+
+ final = await_signage_ai_job(base, JSON.parse(result.body)["id"].as_s, headers)
+ final["state"].as_s.should eq "failed"
+ final["error_kind"].as_s.should eq "invalid_response"
+ final["error_message"].as_s.should eq "the model did not answer with JSON"
+ # a failed call still spent tokens
+ final["cost_units"].as_f.should eq 1280.0
+ end
+
+ it "retries once at a larger output budget, then fails as invalid_response when still cut off" do
+ setup_signage_ai
+ _, headers = Spec::Authentication.authentication
+ level = map_ai_level
+ requests = [] of JSON::Any
+
+ HttpMocks.map_ai_vendor(%({"summary":"partial","ops":[{"op":"set_attr"), finish: "length", requests: requests)
+
+ result = client.post(File.join(base, "edit"), headers: headers, body: {zone_id: level.id, instruction: "x", context: map_ai_context}.to_json)
+ final = await_signage_ai_job(base, JSON.parse(result.body)["id"].as_s, headers)
+ final["state"].as_s.should eq "failed"
+ final["error_kind"].as_s.should eq "invalid_response"
+ final["error_message"].as_s.should contain "cut off"
+ # both attempts were billed
+ final["cost_units"].as_f.should eq 2560.0
+
+ requests.size.should eq 2
+ requests[0]["max_completion_tokens"].as_i.should eq MAP_AI_MAX_OUTPUT_TOKENS
+ requests[1]["max_completion_tokens"].as_i.should eq MAP_AI_RETRY_OUTPUT_TOKENS
+ end
+
+ it "finishes with the second attempt when the first was cut off" do
+ setup_signage_ai
+ _, headers = Spec::Authentication.authentication
+ level = map_ai_level
+ requests = [] of JSON::Any
+
+ HttpMocks.map_ai_vendor_cut_off(MAP_AI_ANSWER, requests)
+
+ result = client.post(File.join(base, "edit"), headers: headers, body: {zone_id: level.id, instruction: "x", context: map_ai_context}.to_json)
+ final = await_signage_ai_job(base, JSON.parse(result.body)["id"].as_s, headers)
+ final["state"].as_s.should eq "done"
+ final["ops"].as_a.size.should eq 1
+ final["usage"]["attempts"].as_i.should eq 2
+ final["usage"]["total_tokens"].as_i.should eq 2560
+ final["usage"]["finish"].as_s.should eq "stop"
+ final["cost_units"].as_f.should eq 2560.0
+
+ requests.size.should eq 2
+ requests[1]["max_completion_tokens"].as_i.should eq MAP_AI_RETRY_OUTPUT_TOKENS
+ end
+
+ it "keeps an op with an unknown anchor, flagged rather than dropped" do
+ setup_signage_ai
+ _, headers = Spec::Authentication.authentication
+ level = map_ai_level
+
+ answer = {
+ summary: "s",
+ ops: [
+ {op: "set_attr", why: "w", target: {a: "e404", id: nil, h: nil}, name: "fill", value: "#fff"},
+ {op: "set_attr", why: "w", target: {a: "e5", id: nil, h: nil}, name: "fill", value: "#fff"},
+ ],
+ questions: [] of String,
+ }.to_json
+ HttpMocks.map_ai_vendor(answer)
+
+ result = client.post(File.join(base, "edit"), headers: headers, body: {zone_id: level.id, instruction: "x", context: map_ai_context}.to_json)
+ final = await_signage_ai_job(base, JSON.parse(result.body)["id"].as_s, headers)
+ final["state"].as_s.should eq "done"
+
+ ops = final["ops"].as_a
+ ops.size.should eq 2
+ ops[0].as_h.has_key?("server_issue").should be_false
+ final["server_issues"].as_h.keys.should eq ["op-1"]
+ final["server_issues"]["op-1"].as_s.should eq "anchor e404 is not in the index"
+ end
+
+ it "records a refusal as a moderation failure" do
+ setup_signage_ai
+ _, headers = Spec::Authentication.authentication
+ level = map_ai_level
+
+ HttpMocks.map_ai_vendor_refused
+
+ result = client.post(File.join(base, "edit"), headers: headers, body: {zone_id: level.id, instruction: "x", context: map_ai_context}.to_json)
+ final = await_signage_ai_job(base, JSON.parse(result.body)["id"].as_s, headers)
+ final["state"].as_s.should eq "failed"
+ final["error_kind"].as_s.should eq "moderation"
+ final["error_message"].as_s.should eq "I can't help with that."
+ end
+
+ it "records a vendor that never answers as a timeout" do
+ setup_signage_ai
+ _, headers = Spec::Authentication.authentication
+ level = map_ai_level
+
+ HttpMocks.map_ai_vendor_timeout
+
+ result = client.post(File.join(base, "edit"), headers: headers, body: {zone_id: level.id, instruction: "x", context: map_ai_context}.to_json)
+ final = await_signage_ai_job(base, JSON.parse(result.body)["id"].as_s, headers)
+ final["state"].as_s.should eq "failed"
+ final["error_kind"].as_s.should eq "timeout"
+ end
+
+ it "drops reasoning_effort when the model rejects it" do
+ setup_signage_ai
+ _, headers = Spec::Authentication.authentication
+ level = map_ai_level
+ requests = [] of JSON::Any
+
+ HttpMocks.map_ai_vendor_no_reasoning(MAP_AI_ANSWER, requests)
+
+ result = client.post(File.join(base, "edit"), headers: headers, body: {zone_id: level.id, instruction: "x", context: map_ai_context}.to_json)
+ final = await_signage_ai_job(base, JSON.parse(result.body)["id"].as_s, headers)
+ final["state"].as_s.should eq "done"
+
+ requests.size.should eq 2
+ requests[0]["reasoning_effort"].as_s.should eq "low"
+ requests[1]["reasoning_effort"]?.should be_nil
+ end
+
+ it "edits through an Anthropic provider with the model the editor chose" do
+ authority, _, _ = setup_signage_ai
+ claude = Model::Generator.signage_ai_provider(
+ authority: authority,
+ name: "claude-#{random_name}",
+ provider: Model::SignageAIProvider::Provider::ANTHROPIC,
+ ).save!
+ claude.credentials_json = {"api_key" => JSON::Any.new("test-key"), "workspace_id" => JSON::Any.new("wrkspc_test")}
+ claude.save!
+ _, headers = Spec::Authentication.authentication
+ level = map_ai_level
+ requests = [] of JSON::Any
+ seen = [] of HTTP::Headers
+
+ HttpMocks.map_ai_anthropic(MAP_AI_ANSWER, requests: requests, headers: seen)
+ HttpMocks.signage_ai_vendor
+
+ result = client.post(
+ File.join(base, "edit"),
+ headers: headers,
+ body: {
+ zone_id: level.id,
+ instruction: "Make meeting room 1.01 light green.",
+ context: map_ai_context,
+ provider_id: claude.id.to_s,
+ model: "claude-fable-5-1",
+ }.to_json,
+ )
+ result.status_code.should eq 202
+ accepted = JSON.parse(result.body)
+ accepted["provider"].as_s.should eq "ANTHROPIC"
+ accepted["model"].as_s.should eq "claude-fable-5-1"
+
+ final = await_signage_ai_job(base, accepted["id"].as_s, headers)
+ final["state"].as_s.should eq "done"
+ final["summary"].as_s.should eq "Recoloured Meeting 1.01 light green."
+ final["ops"].as_a.size.should eq 1
+ final["usage"]["model"].as_s.should eq "claude-fable-5-1"
+ final["usage"]["served_by"].as_s.should eq "claude-fable-5-1"
+ final["usage"]["total_tokens"].as_i.should eq 1590
+ final["cost_units"].as_f.should eq 1590.0
+
+ requests.size.should eq 1
+ sent = requests.first
+ sent["model"].as_s.should eq "claude-fable-5-1"
+ sent["max_tokens"].as_i.should eq MAP_AI_MAX_OUTPUT_TOKENS
+ sent["system"].as_s.should contain "You edit SVG floor-plan maps"
+ sent["output_config"]["format"]?.should be_nil
+ sent["tools"][0]["name"].as_s.should eq "map_edit"
+ sent["tools"][0]["input_schema"].should eq Llm::Schema.openai
+ sent["tools"][0]["strict"]?.should be_nil
+ sent["tool_choice"]["type"].as_s.should eq "auto"
+ sent["tool_choice"]["disable_parallel_tool_use"].as_bool.should be_true
+ sent["output_config"]["effort"].as_s.should eq MAP_AI_ANTHROPIC_EFFORT
+ sent["fallbacks"].as_s.should eq "default"
+ sent["thinking"]?.should be_nil
+ sent["temperature"]?.should be_nil
+ sent["response_format"]?.should be_nil
+ messages = sent["messages"].as_a
+ messages.size.should eq 1
+ messages.first["role"].as_s.should eq "user"
+ parts = messages.first["content"].as_a
+ parts.first["type"].as_s.should eq "text"
+ parts.first["text"].as_s.should contain "e2 | area-1.01-status"
+ parts.last["text"].as_s.should end_with "INSTRUCTION\nMake meeting room 1.01 light green."
+
+ seen.size.should eq 1
+ seen.first["x-api-key"].should eq "test-key"
+ seen.first["anthropic-workspace-id"].should eq "wrkspc_test"
+ seen.first["anthropic-version"].should eq "2023-06-01"
+ seen.first["anthropic-beta"].should eq "server-side-fallback-2026-07-01"
+ seen.first["Authorization"]?.should be_nil
+ end
+
+ it "sends no fallbacks to a model that takes none, and drops them when one rejects them" do
+ authority, _, _ = setup_signage_ai
+ claude = Model::Generator.signage_ai_provider(
+ authority: authority,
+ name: "claude-#{random_name}",
+ provider: Model::SignageAIProvider::Provider::ANTHROPIC,
+ ).save!
+ _, headers = Spec::Authentication.authentication
+ level = map_ai_level
+ requests = [] of JSON::Any
+ seen = [] of HTTP::Headers
+
+ HttpMocks.map_ai_anthropic(MAP_AI_ANSWER, requests: requests, headers: seen)
+ HttpMocks.signage_ai_vendor
+
+ result = client.post(
+ File.join(base, "edit"),
+ headers: headers,
+ body: {zone_id: level.id, instruction: "x", context: map_ai_context, provider_id: claude.id.to_s, model: "claude-haiku-5-5"}.to_json,
+ )
+ final = await_signage_ai_job(base, JSON.parse(result.body)["id"].as_s, headers)
+ final["state"].as_s.should eq "done"
+ requests.size.should eq 1
+ requests.first["fallbacks"]?.should be_nil
+ seen.first["anthropic-beta"]?.should be_nil
+ seen.first["anthropic-workspace-id"]?.should be_nil
+
+ requests.clear
+ WebMock.reset
+ HttpMocks.map_ai_anthropic_no_fallbacks(MAP_AI_ANSWER, requests)
+ HttpMocks.signage_ai_vendor
+
+ result = client.post(
+ File.join(base, "edit"),
+ headers: headers,
+ body: {zone_id: level.id, instruction: "x", context: map_ai_context, provider_id: claude.id.to_s, model: "claude-opus-5-5"}.to_json,
+ )
+ final = await_signage_ai_job(base, JSON.parse(result.body)["id"].as_s, headers)
+ final["state"].as_s.should eq "done"
+ requests.size.should eq 2
+ requests.first["fallbacks"].as_s.should eq "default"
+ requests.last["fallbacks"]?.should be_nil
+ final["usage"]["attempts"].as_i.should eq 1
+ end
+
+ it "records an Anthropic refusal as a moderation failure" do
+ authority, _, _ = setup_signage_ai
+ claude = Model::Generator.signage_ai_provider(
+ authority: authority,
+ name: "claude-#{random_name}",
+ provider: Model::SignageAIProvider::Provider::ANTHROPIC,
+ ).save!
+ _, headers = Spec::Authentication.authentication
+ level = map_ai_level
+
+ HttpMocks.map_ai_anthropic_refused
+ HttpMocks.signage_ai_vendor
+
+ result = client.post(
+ File.join(base, "edit"),
+ headers: headers,
+ body: {zone_id: level.id, instruction: "x", context: map_ai_context, provider_id: claude.id.to_s}.to_json,
+ )
+ result.status_code.should eq 202
+ JSON.parse(result.body)["model"].as_s.should eq MAP_AI_DEFAULT_ANTHROPIC_MODEL
+ final = await_signage_ai_job(base, JSON.parse(result.body)["id"].as_s, headers)
+ final["state"].as_s.should eq "failed"
+ final["error_kind"].as_s.should eq "moderation"
+ final["error_message"].as_s.should eq "This request was declined."
+ end
+
+ it "answers 429 by the map quota, not the image quota" do
+ _, _, provider = setup_signage_ai(quotas: {"map_user_per_day" => JSON::Any.new(1_i64), "user_per_day" => JSON::Any.new(60_i64)})
+ user, headers = Spec::Authentication.authentication
+ level = map_ai_level
+
+ Model::Generator.signage_ai_job(user: user, provider: provider, candidates: 1, kind: Model::SignageAIJob::Kind::MapEdit).save!
+
+ result = client.post(File.join(base, "edit"), headers: headers, body: {zone_id: level.id, instruction: "x", context: map_ai_context}.to_json)
+ result.status_code.should eq 429
+ JSON.parse(result.body)["kind"].as_s.should eq "quota"
+ end
+
+ it "does not count image jobs against the map quota" do
+ _, _, provider = setup_signage_ai(quotas: {"map_user_per_day" => JSON::Any.new(1_i64), "user_per_day" => JSON::Any.new(1_i64)})
+ user, headers = Spec::Authentication.authentication
+ level = map_ai_level
+ Model::Generator.signage_ai_job(user: user, provider: provider, candidates: 4).save!
+
+ HttpMocks.map_ai_vendor(MAP_AI_ANSWER)
+
+ result = client.post(File.join(base, "edit"), headers: headers, body: {zone_id: level.id, instruction: "x", context: map_ai_context}.to_json)
+ result.status_code.should eq 202
+ await_signage_ai_job(base, JSON.parse(result.body)["id"].as_s, headers)
+ end
+
+ it "refuses a write with a read-only scope" do
+ setup_signage_ai
+ level = map_ai_level
+ _, scoped_headers = Spec::Authentication.authentication(scope: [Model::UserJWT::Scope.new("map_ai", :read)])
+
+ result = client.post(File.join(base, "edit"), headers: scoped_headers, body: {zone_id: level.id, instruction: "x", context: map_ai_context}.to_json)
+ result.status_code.should eq 403
+ end
+
+ it "lets a concierge user with manage on the org zone edit" do
+ setup_signage_ai
+ _, headers = Spec::Authentication.authentication(sys_admin: false, support: false, groups: ["concierge"])
+ level = map_ai_level
+
+ HttpMocks.map_ai_vendor(MAP_AI_ANSWER)
+
+ result = client.post(File.join(base, "edit"), headers: headers, body: {zone_id: level.id, instruction: "x", context: map_ai_context}.to_json)
+ result.status_code.should eq 202
+ final = await_signage_ai_job(base, JSON.parse(result.body)["id"].as_s, headers)
+ final["state"].as_s.should eq "done"
+ end
+
+ it "refuses a caller who cannot update the zone" do
+ setup_signage_ai
+ _, headers = Spec::Authentication.authentication(sys_admin: false, support: false)
+ level = map_ai_level
+
+ result = client.post(File.join(base, "edit"), headers: headers, body: {zone_id: level.id, instruction: "x", context: map_ai_context}.to_json)
+ result.status_code.should eq 403
+ Model::SignageAIJob.count.should eq 0
+ end
+
+ # Any signage group with Update may PATCH any zone today; the AI gate
+ # does not inherit that grant.
+ it "refuses the broad signage grant" do
+ authority, _, _ = setup_signage_ai
+ user, headers = Spec::Authentication.authentication(sys_admin: false, support: false)
+ signage_group(authority, user, Model::Permissions::Read | Model::Permissions::Update)
+ level = map_ai_level
+
+ result = client.post(File.join(base, "edit"), headers: headers, body: {zone_id: level.id, instruction: "x", context: map_ai_context}.to_json)
+ result.status_code.should eq 403
+ end
+
+ it "hides a zone outside the caller's organisation" do
+ setup_signage_ai
+ _, headers = Spec::Authentication.authentication(sys_admin: false, support: false, groups: ["concierge"])
+
+ other_org = Model::Generator.zone
+ other_org.tags = Set{"org"}
+ other_org.save!
+ level = Model::Generator.zone
+ level.parent_id = other_org.id
+ level.save!
+
+ result = client.post(File.join(base, "edit"), headers: headers, body: {zone_id: level.id, instruction: "x", context: map_ai_context}.to_json)
+ result.status_code.should eq 404
+
+ missing = client.post(File.join(base, "edit"), headers: headers, body: {zone_id: "zone-does-not-exist", instruction: "x", context: map_ai_context}.to_json)
+ missing.status_code.should eq 404
+ end
+
+ it "refuses a context over the cap, or of the wrong shape" do
+ setup_signage_ai
+ _, headers = Spec::Authentication.authentication
+ level = map_ai_level
+
+ huge = map_ai_context
+ huge.as_h["subtrees"] = JSON::Any.new({"e2" => JSON::Any.new("x" * (MAP_AI_MAX_CONTEXT_BYTES + 1))})
+ result = client.post(File.join(base, "edit"), headers: headers, body: {zone_id: level.id, instruction: "x", context: huge}.to_json)
+ result.status_code.should eq 422
+
+ wrong = client.post(File.join(base, "edit"), headers: headers, body: {zone_id: level.id, instruction: "x", context: {schema: "something/else", index: [] of String}}.to_json)
+ wrong.status_code.should eq 422
+
+ none = client.post(File.join(base, "edit"), headers: headers, body: {zone_id: level.id, instruction: "x"}.to_json)
+ none.status_code.should eq 422
+
+ blank = client.post(File.join(base, "edit"), headers: headers, body: {zone_id: level.id, instruction: " ", context: map_ai_context}.to_json)
+ blank.status_code.should eq 422
+
+ Model::SignageAIJob.count.should eq 0
+ end
+
+ it "replays an accepted submission rather than spending again" do
+ setup_signage_ai
+ user, headers = Spec::Authentication.authentication
+ level = map_ai_level
+
+ HttpMocks.map_ai_vendor(MAP_AI_ANSWER)
+
+ body = {zone_id: level.id, instruction: "x", context: map_ai_context, idempotency_key: "map-#{random_name}"}.to_json
+ first = client.post(File.join(base, "edit"), headers: headers, body: body)
+ first.status_code.should eq 202
+ job_id = JSON.parse(first.body)["id"].as_s
+
+ second = client.post(File.join(base, "edit"), headers: headers, body: body)
+ second.status_code.should eq 202
+ JSON.parse(second.body)["id"].as_s.should eq job_id
+
+ Model::SignageAIJob.where(user_id: user.id.as(String)).to_a.size.should eq 1
+ await_signage_ai_job(base, job_id, headers)
+ end
+
+ it "answers 409 when the idempotency key belongs to an image job" do
+ _, _, provider = setup_signage_ai
+ user, headers = Spec::Authentication.authentication
+ level = map_ai_level
+ image_job = Model::Generator.signage_ai_job(user: user, provider: provider)
+ image_job.idempotency_key = "shared-#{random_name}"
+ image_job.save!
+
+ result = client.post(
+ File.join(base, "edit"),
+ headers: headers,
+ body: {zone_id: level.id, instruction: "x", context: map_ai_context, idempotency_key: image_job.idempotency_key}.to_json,
+ )
+ result.status_code.should eq 409
+ Model::SignageAIJob.where(user_id: user.id.as(String)).to_a.size.should eq 1
+ end
+
+ it "feeds the parent chain into the prompt" do
+ _, _, provider = setup_signage_ai
+ user, headers = Spec::Authentication.authentication
+ level = map_ai_level
+ requests = [] of JSON::Any
+
+ parent = Model::Generator.signage_ai_job(user: user, provider: provider, candidates: 1, kind: Model::SignageAIJob::Kind::MapEdit)
+ parent.state = Model::SignageAIJob::State::Done
+ parent.result = JSON.parse(%({"summary":"Recoloured it.","ops":[{"op":"set_attr","target":{"a":"e2","id":null,"h":null},"name":"fill","value":"#90ee90","why":"w"}],"server_issues":{},"questions":[],"resolution":{"decision":"rejected","accepted_op_ids":[],"rejected_op_ids":["op-1"],"reasons":["too bright"]}}))
+ parent.save!
+
+ HttpMocks.map_ai_vendor(MAP_AI_ANSWER, requests: requests)
+
+ result = client.post(
+ File.join(base, "edit"),
+ headers: headers,
+ body: {zone_id: level.id, instruction: "Paler.", context: map_ai_context, parent_job_id: parent.id}.to_json,
+ )
+ result.status_code.should eq 202
+ JSON.parse(result.body)["parent_job_id"].as_s.should eq parent.id.to_s
+ await_signage_ai_job(base, JSON.parse(result.body)["id"].as_s, headers)
+
+ text = requests.first["messages"].as_a.last["content"].as_a.map(&.["text"].as_s).join("\n")
+ text.should contain "Turn 1 instruction: recolour the boardroom light blue"
+ text.should contain "Turn 1 outcome: rejected; rejected: op-1; reasons: too bright"
+ text.index!("Turn 1 instruction").should be < text.index!("INSTRUCTION\nPaler.")
+ end
+
+ it "refuses an image job or another domain's job as a parent" do
+ _, _, provider = setup_signage_ai
+ user, headers = Spec::Authentication.authentication
+ level = map_ai_level
+ image_job = Model::Generator.signage_ai_job(user: user, provider: provider).save!
+
+ result = client.post(File.join(base, "edit"), headers: headers, body: {zone_id: level.id, instruction: "x", context: map_ai_context, parent_job_id: image_job.id}.to_json)
+ result.status_code.should eq 404
+ # nothing was written and no slot is held
+ Model::SignageAIJob.where("kind = ?", "MAP_EDIT").to_a.should be_empty
+ Llm.slots.available.should eq MAP_AI_MAX_CALLS
+ end
+ end
+
+ describe "jobs" do
+ it "lists only map edit jobs, by zone, and hides them from the signage list" do
+ _, _, provider = setup_signage_ai
+ user, headers = Spec::Authentication.authentication
+
+ image_job = Model::Generator.signage_ai_job(user: user, provider: provider).save!
+ here = Model::Generator.signage_ai_job(user: user, provider: provider, candidates: 1, kind: Model::SignageAIJob::Kind::MapEdit)
+ here.request = JSON.parse(%({"zone_id":"zone-here"}))
+ here.save!
+ elsewhere = Model::Generator.signage_ai_job(user: user, provider: provider, candidates: 1, kind: Model::SignageAIJob::Kind::MapEdit)
+ elsewhere.request = JSON.parse(%({"zone_id":"zone-elsewhere"}))
+ elsewhere.save!
+
+ result = client.get(File.join(base, "jobs"), headers: headers)
+ result.status_code.should eq 200
+ ids = JSON.parse(result.body).as_a.map(&.["id"].as_s)
+ ids.should contain here.id.to_s
+ ids.should contain elsewhere.id.to_s
+ ids.should_not contain image_job.id.to_s
+
+ by_zone = JSON.parse(client.get(File.join(base, "jobs") + "?zone_id=zone-here", headers: headers).body).as_a
+ by_zone.map(&.["id"].as_s).should eq [here.id.to_s]
+ by_zone.first["zone_id"].as_s.should eq "zone-here"
+
+ signage = JSON.parse(client.get(File.join(SignageAI.base_route, "jobs"), headers: headers).body).as_a.map(&.["id"].as_s)
+ signage.should contain image_job.id.to_s
+ signage.should_not contain here.id.to_s
+
+ signage_maps = JSON.parse(client.get(File.join(SignageAI.base_route, "jobs") + "?kind=map_edit", headers: headers).body).as_a.map(&.["id"].as_s)
+ signage_maps.should contain here.id.to_s
+ signage_maps.should_not contain image_job.id.to_s
+
+ client.get(File.join(base, "jobs") + "?kind=nope", headers: headers).status_code.should eq 422
+ end
+
+ it "answers 404 for an image job on the map routes" do
+ _, _, provider = setup_signage_ai
+ user, headers = Spec::Authentication.authentication
+ image_job = Model::Generator.signage_ai_job(user: user, provider: provider).save!
+
+ client.get(File.join(base, "jobs", image_job.id.to_s), headers: headers).status_code.should eq 404
+ end
+
+ it "long polls and cancels like signage" do
+ _, _, provider = setup_signage_ai
+ user, headers = Spec::Authentication.authentication
+ job = Model::Generator.signage_ai_job(user: user, provider: provider, candidates: 1, kind: Model::SignageAIJob::Kind::MapEdit).save!
+ job_id = job.id.as(UUID)
+
+ spawn do
+ sleep 700.milliseconds
+ Model::SignageAIJob.bump_version(job_id)
+ end
+
+ started = Time.utc
+ result = client.get(File.join(base, "jobs", job_id.to_s) + "?wait=20&since=#{job.version}", headers: headers)
+ (Time.utc - started).should be < 10.seconds
+ result.status_code.should eq 200
+ JSON.parse(result.body)["version"].as_i.should eq job.version + 1
+
+ cancelled = client.post(File.join(base, "jobs", job_id.to_s, "cancel"), headers: headers)
+ cancelled.status_code.should eq 200
+ Model::SignageAIJob.find!(job_id).cancel_requested.should be_true
+ end
+ end
+
+ describe "resolve" do
+ it "records what the person did with the answer" do
+ _, _, provider = setup_signage_ai
+ user, headers = Spec::Authentication.authentication
+ job = Model::Generator.signage_ai_job(user: user, provider: provider, candidates: 1, kind: Model::SignageAIJob::Kind::MapEdit)
+ job.state = Model::SignageAIJob::State::Done
+ job.result = JSON.parse(%({"schema_version":1,"summary":"s","ops":[{"op":"no_change","reason":"r","why":"w"}],"server_issues":{},"questions":[]}))
+ job.save!
+
+ result = client.post(
+ File.join(base, "jobs", job.id.to_s, "resolve"),
+ headers: headers,
+ body: {decision: "partial", accepted_op_ids: ["op-1"], rejected_op_ids: ["op-2"], reasons: {"op-2" => "wrong room"}, saved_upload_id: "upload-123"}.to_json,
+ )
+ result.status_code.should eq 200
+
+ body = JSON.parse(result.body)
+ body["version"].as_i.should eq job.version + 1
+ resolution = body["resolution"]
+ resolution["decision"].as_s.should eq "partial"
+ resolution["accepted_op_ids"].as_a.map(&.as_s).should eq ["op-1"]
+ resolution["reasons"]["op-2"].as_s.should eq "wrong room"
+ resolution["saved_upload_id"].as_s.should eq "upload-123"
+ resolution["resolved_by"].as_s.should eq user.id
+ # the ops are untouched
+ body["ops"].as_a.size.should eq 1
+
+ stored = Model::SignageAIJob.find!(job.id.as(UUID))
+ stored.result["resolution"]["decision"].as_s.should eq "partial"
+
+ bad = client.post(File.join(base, "jobs", job.id.to_s, "resolve"), headers: headers, body: {decision: "meh"}.to_json)
+ bad.status_code.should eq 422
+ end
+
+ it "refuses somebody else's job, and a job still running" do
+ authority, _, provider = setup_signage_ai
+ _, headers = Spec::Authentication.authentication(sys_admin: false, support: false)
+
+ someone_else = Model::Generator.user(authority: authority).save!
+ theirs = Model::Generator.signage_ai_job(user: someone_else, provider: provider, candidates: 1, kind: Model::SignageAIJob::Kind::MapEdit, state: Model::SignageAIJob::State::Done).save!
+ client.post(File.join(base, "jobs", theirs.id.to_s, "resolve"), headers: headers, body: {decision: "applied"}.to_json).status_code.should eq 403
+
+ user, own_headers = Spec::Authentication.authentication
+ running = Model::Generator.signage_ai_job(user: user, provider: provider, candidates: 1, kind: Model::SignageAIJob::Kind::MapEdit, state: Model::SignageAIJob::State::Running).save!
+ client.post(File.join(base, "jobs", running.id.to_s, "resolve"), headers: own_headers, body: {decision: "applied"}.to_json).status_code.should eq 422
+ end
+ end
+
+ describe "usage" do
+ it "sums map edit spend only, for support" do
+ _, _, provider = setup_signage_ai
+ user, headers = Spec::Authentication.authentication
+
+ image_job = Model::Generator.signage_ai_job(user: user, provider: provider, candidates: 2)
+ image_job.cost_units = 10.0
+ image_job.save!
+ map_job = Model::Generator.signage_ai_job(user: user, provider: provider, candidates: 1, kind: Model::SignageAIJob::Kind::MapEdit)
+ map_job.cost_units = 1280.0
+ map_job.save!
+
+ result = client.get(File.join(base, "usage"), headers: headers)
+ result.status_code.should eq 200
+ rows = Array(Model::SignageAIJob::UsageRow).from_json(result.body)
+ rows.size.should eq 1
+ rows.first.kind.should eq "map_edit"
+ rows.first.cost_units.should eq 1280.0
+ rows.first.candidates.should eq 1
+
+ # the signage usage report now carries the kind as well
+ signage_rows = Array(Model::SignageAIJob::UsageRow).from_json(client.get(File.join(SignageAI.base_route, "usage"), headers: headers).body)
+ signage_rows.map(&.kind).sort!.should eq ["generate", "map_edit"]
+
+ _, regular = Spec::Authentication.authentication(sys_admin: false, support: false)
+ client.get(File.join(base, "usage"), headers: regular).status_code.should eq 403
+ end
+ end
+ end
+end
diff --git a/spec/controllers/signage/signage_ai_providers_spec.cr b/spec/controllers/signage/signage_ai_providers_spec.cr
index 9dfbc2eb..4394654b 100644
--- a/spec/controllers/signage/signage_ai_providers_spec.cr
+++ b/spec/controllers/signage/signage_ai_providers_spec.cr
@@ -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
@@ -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
@@ -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(
@@ -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
@@ -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(
diff --git a/spec/controllers/signage/signage_ai_spec.cr b/spec/controllers/signage/signage_ai_spec.cr
index 58315078..781f3c9b 100644
--- a/spec/controllers/signage/signage_ai_spec.cr
+++ b/spec/controllers/signage/signage_ai_spec.cr
@@ -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
@@ -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
@@ -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
diff --git a/spec/fixtures/map_ai/context.json b/spec/fixtures/map_ai/context.json
new file mode 100644
index 00000000..b153e907
--- /dev/null
+++ b/spec/fixtures/map_ai/context.json
@@ -0,0 +1,228 @@
+{
+ "schema": "placeos.map-ai-context/1",
+ "viewBox": [
+ 0,
+ 0,
+ 1725.26,
+ 619
+ ],
+ "image_px": [
+ 2048,
+ 735
+ ],
+ "units_per_px": 0.8424,
+ "protected_ids": [
+ "table-01.004",
+ "area-1.02-status"
+ ],
+ "duplicate_ids": [
+ "table-01.015"
+ ],
+ "index": [
+ {
+ "a": "e1",
+ "id": null,
+ "tag": "g",
+ "cls": null,
+ "label": "walls",
+ "bbox": [
+ 0,
+ 0,
+ 1725.26,
+ 619
+ ],
+ "p": null,
+ "z": 1,
+ "kind": "layer-group",
+ "prot": false,
+ "h": "a1a1a1a1",
+ "fill": null,
+ "in": null,
+ "piece": null
+ },
+ {
+ "a": "e2",
+ "id": "area-1.01-status",
+ "tag": "path",
+ "cls": "cls-3",
+ "label": "Meeting 1.01",
+ "bbox": [
+ 100,
+ 80,
+ 120,
+ 90
+ ],
+ "p": "e1",
+ "z": 2,
+ "kind": "zone",
+ "prot": false,
+ "h": "b2b2b2b2",
+ "fill": null,
+ "in": null,
+ "piece": null
+ },
+ {
+ "a": "e3",
+ "id": "area-1.02-status",
+ "tag": "path",
+ "cls": "cls-3",
+ "label": "Meeting 1.02",
+ "bbox": [
+ 240,
+ 80,
+ 120,
+ 90
+ ],
+ "p": "e1",
+ "z": 3,
+ "kind": "zone",
+ "prot": true,
+ "h": "c3c3c3c3",
+ "fill": null,
+ "in": null,
+ "piece": null
+ },
+ {
+ "a": "e4",
+ "id": "table-01.004",
+ "tag": "rect",
+ "cls": "cls-12",
+ "label": "Desk 01.004",
+ "bbox": [
+ 640,
+ 420,
+ 30,
+ 20
+ ],
+ "p": "e1",
+ "z": 4,
+ "kind": "bookable",
+ "prot": true,
+ "h": "d4d4d4d4",
+ "fill": null,
+ "in": null,
+ "piece": null
+ },
+ {
+ "a": "e5",
+ "id": "table-01.005",
+ "tag": "rect",
+ "cls": "cls-12",
+ "label": "Desk 01.005",
+ "bbox": [
+ 680,
+ 420,
+ 30,
+ 20
+ ],
+ "p": "e1",
+ "z": 5,
+ "kind": "bookable",
+ "prot": false,
+ "h": "e5e5e5e5",
+ "fill": null,
+ "in": null,
+ "piece": null
+ },
+ {
+ "a": "e6",
+ "id": "label-1.01",
+ "tag": "text",
+ "cls": null,
+ "label": "Meeting 1.01",
+ "bbox": [
+ 110,
+ 60,
+ 60,
+ 12
+ ],
+ "p": "e1",
+ "z": 6,
+ "kind": "art",
+ "prot": false,
+ "h": "f6f6f6f6",
+ "fill": null,
+ "in": null,
+ "piece": null
+ },
+ {
+ "a": "e7",
+ "id": null,
+ "tag": "path",
+ "cls": "cls-1",
+ "label": null,
+ "bbox": [
+ 0,
+ 0,
+ 1725.26,
+ 4
+ ],
+ "p": "e1",
+ "z": 7,
+ "kind": "art",
+ "prot": false,
+ "h": "07070707",
+ "fill": null,
+ "in": null,
+ "piece": null
+ }
+ ],
+ "labels": [],
+ "shortlist": [
+ "e2",
+ "e4"
+ ],
+ "subtrees": {
+ "e2": "",
+ "e4": ""
+ },
+ "style_rules": [
+ ".cls-1{fill:none;stroke:#231f20}",
+ ".cls-3{fill:#fff}",
+ ".cls-12{fill:#d1d3d4}"
+ ],
+ "markup": {
+ "schema": "placeos.map-markup/1",
+ "viewBox": [
+ 0,
+ 0,
+ 1725.26,
+ 619
+ ],
+ "annotations": [
+ {
+ "n": 1,
+ "kind": "box",
+ "x": 630,
+ "y": 410,
+ "width": 90,
+ "height": 40,
+ "hits": [
+ "e4",
+ "e5"
+ ],
+ "near": [
+ "e7"
+ ]
+ },
+ {
+ "n": 2,
+ "kind": "pin",
+ "x": 160,
+ "y": 120,
+ "text": "this room",
+ "hits": [
+ "e2"
+ ]
+ }
+ ],
+ "selected": [
+ "area-1.01-status"
+ ]
+ },
+ "stats": {
+ "elements": 748,
+ "ids": 84,
+ "bytes": 134861
+ }
+}
diff --git a/spec/llm_anthropic_spec.cr b/spec/llm_anthropic_spec.cr
new file mode 100644
index 00000000..ca919c0d
--- /dev/null
+++ b/spec/llm_anthropic_spec.cr
@@ -0,0 +1,59 @@
+require "./helper"
+
+module PlaceOS::Api
+ describe Llm::Adapters::AnthropicMessages do
+ it "rejects missing, unexpected, malformed and multiple proposal tool calls" do
+ row = Model::Generator.signage_ai_provider(provider: Model::SignageAIProvider::Provider::ANTHROPIC)
+ row.credentials_json = {"api_key" => JSON::Any.new("test-key")}
+ adapter = Llm::Adapters::AnthropicMessages.new(row)
+ request = Llm::Request.new(system: "x", parts: [Llm::TextPart.new("x")] of Llm::Part,
+ model: "claude-fable-5-1", max_output_tokens: 100, schema: Llm::Schema.openai)
+ [
+ %([{"type":"text","text":"no proposal"}]),
+ %([{"type":"tool_use","name":"other","input":{}}]),
+ %([{"type":"tool_use","name":"map_edit","input":"invalid"}]),
+ %([{"type":"tool_use","name":"map_edit","input":{}},{"type":"tool_use","name":"map_edit","input":{}}]),
+ ].each do |blocks|
+ WebMock.reset
+ WebMock.stub(:post, HttpMocks::ANTHROPIC_MESSAGES).to_return(
+ status: 200, body: %({"stop_reason":"tool_use","content":#{blocks}}))
+ expect_raises(Error::ImageGen::InvalidResponse, "the model did not return one map_edit tool result") do
+ adapter.complete(request)
+ end
+ end
+ end
+
+ it "reports a proposal cut off at max_tokens as length so the runner can retry" do
+ row = Model::Generator.signage_ai_provider(provider: Model::SignageAIProvider::Provider::ANTHROPIC)
+ row.credentials_json = {"api_key" => JSON::Any.new("test-key")}
+ adapter = Llm::Adapters::AnthropicMessages.new(row)
+ request = Llm::Request.new(system: "x", parts: [Llm::TextPart.new("x")] of Llm::Part,
+ model: "claude-fable-5-1", max_output_tokens: 100, schema: Llm::Schema.openai)
+ [
+ %([{"type":"text","text":"Working through the labels first"}]),
+ %([{"type":"tool_use","name":"map_edit","input":{"schema_version":1,"summary":"Renames"}}]),
+ ].each do |blocks|
+ WebMock.reset
+ WebMock.stub(:post, HttpMocks::ANTHROPIC_MESSAGES).to_return(
+ status: 200, body: %({"stop_reason":"max_tokens","content":#{blocks}}))
+ result = adapter.complete(request)
+ result.finish.should eq "length"
+ result.text.should eq ""
+ end
+ end
+
+ it "reads the proposal tool input and ignores accompanying prose" do
+ row = Model::Generator.signage_ai_provider(provider: Model::SignageAIProvider::Provider::ANTHROPIC)
+ row.credentials_json = {"api_key" => JSON::Any.new("test-key")}
+ adapter = Llm::Adapters::AnthropicMessages.new(row)
+ request = Llm::Request.new(system: "x", parts: [Llm::TextPart.new("x")] of Llm::Part,
+ model: "claude-fable-5-1", max_output_tokens: 100, schema: Llm::Schema.openai)
+ proposal = %({"schema_version":1,"summary":"No change","ops":[],"questions":[]})
+ WebMock.stub(:post, HttpMocks::ANTHROPIC_MESSAGES).to_return(status: 200,
+ body: %({"stop_reason":"tool_use","content":[{"type":"text","text":"Here is the proposal."},{"type":"tool_use","name":"map_edit","input":#{proposal}}]}))
+ result = adapter.complete(request)
+ JSON.parse(result.text).should eq JSON.parse(proposal)
+ result.finish.should eq "stop"
+ end
+ end
+end
diff --git a/spec/llm_prompt_spec.cr b/spec/llm_prompt_spec.cr
new file mode 100644
index 00000000..97199ef4
--- /dev/null
+++ b/spec/llm_prompt_spec.cr
@@ -0,0 +1,530 @@
+require "./helper"
+
+module PlaceOS::Api
+ def self.assert_strict_objects(node : JSON::Any) : Nil
+ if (hash = node.as_h?)
+ if hash["properties"]?
+ hash["additionalProperties"].as_bool.should be_false
+ hash["required"].as_a.map(&.as_s).sort!.should eq hash["properties"].as_h.keys.sort!
+ end
+ hash.each_value { |value| assert_strict_objects(value) }
+ elsif (list = node.as_a?)
+ list.each { |value| assert_strict_objects(value) }
+ end
+ end
+
+ def self.op(json : String) : Hash(String, JSON::Any)
+ JSON.parse(json).as_h
+ end
+
+ describe Llm::Prompt do
+ context = JSON.parse(File.read(File.join(__DIR__, "fixtures/map_ai/context.json")))
+ image = Llm::ImagePart.new(bytes: Bytes[0x89, 0x50, 0x4E, 0x47], mime: "image/png")
+
+ it "sends the rules, the op reference and the schema first, the instruction last" do
+ request = Llm::Prompt.build(Llm::Prompt::Options.new(
+ context: context,
+ instruction: "Make meeting room 1.01 light green.",
+ model: "gpt-5.4-mini",
+ ))
+
+ request.system.should contain "You edit SVG floor-plan maps"
+ request.system.should contain "set_attr: target, name, value"
+ request.system.should contain "Preserve the apparent original font weight and style shown in the snapshot"
+ request.system.should contain "use bold only when the original label is bold"
+ request.system.should contain "set fill-opacity to 0.3 explicitly"
+ request.system.should contain "Preserve the furniture unless the instruction explicitly requests its removal"
+ request.system.should contain "expanding the canvas is part of that request"
+ request.system.should contain "Never crop the old viewBox"
+ request.system.should contain "A new room needs a readable name label"
+ request.system.should contain "make a usable doorway through that shared wall"
+ request.schema.should_not be_nil
+ request.model.should eq "gpt-5.4-mini"
+ request.max_output_tokens.should eq MAP_AI_MAX_OUTPUT_TOKENS
+
+ text = request.text
+ text.index!("MAP\n").should be < text.index!("INDEX\n")
+ text.index!("INDEX\n").should be < text.index!("SUBTREES")
+ text.index!("SUBTREES").should be < text.index!("ANNOTATIONS")
+ text.index!("ANNOTATIONS").should be < text.index!("INSTRUCTION\n")
+ text.should end_with "INSTRUCTION\nMake meeting room 1.01 light green."
+
+ # the index carries what the model may ground on, nothing else
+ text.should contain "viewBox: 0 0 1725.26 619"
+ text.should contain "protected ids (bound to bookings): table-01.004, area-1.02-status"
+ text.should contain "e4 | table-01.004 | rect | cls-12 | \"Desk 01.004\" | - | 640 420 30 20 | e1 | - | - | bookable | protected | d4d4d4d4"
+ text.should contain "e7 | - | path | cls-1 | - | - | 0 0 1725.26 4 | e1 | - | - | art | - | 07070707"
+ text.should contain ".cls-12{fill:#d1d3d4}"
+ text.should contain "[e4]\n kinds.index!(&.starts_with?("SUBTREES"))
+ annotations_position = request.parts.index! do |part|
+ part.is_a?(Llm::TextPart) && part.text.includes?("ANNOTATIONS drawn by the person")
+ end
+ kinds.index!("image").should be < annotations_position
+ request.text.should contain "SNAPSHOT bounds in user units:"
+ request.text.should contain "these may include annotations outside the map canvas"
+ request.images.size.should eq 1
+ request.text.should contain "the numbers match the badges on the snapshot"
+ end
+
+ it "includes the parent chain, oldest first, with what the person did" do
+ first = Llm::Prompt::Turn.new(
+ instruction: "Make meeting room 1.01 light green.",
+ ops: [JSON.parse(%({"id":"op-1","op":"set_attr","target":{"a":"e2","id":null,"h":null},"name":"fill","value":"#90ee90","why":"recolours Meeting 1.01","selector":null}))],
+ summary: "Recoloured Meeting 1.01.",
+ resolution: JSON.parse(%({"decision":"rejected","accepted_op_ids":[],"rejected_op_ids":["op-1"],"reasons":{"op-1":"wrong green"}})),
+ )
+ second = Llm::Prompt::Turn.new(instruction: "Use a paler green.", ops: [] of JSON::Any)
+
+ request = Llm::Prompt.build(Llm::Prompt::Options.new(
+ context: context,
+ instruction: "Paler still.",
+ model: "gpt-5.4-mini",
+ history: [first, second],
+ ))
+
+ text = request.text
+ text.index!("PREVIOUS TURNS").should be > text.index!("INDEX\n")
+ text.index!("PREVIOUS TURNS").should be < text.index!("INSTRUCTION\n")
+ text.index!("Turn 1 instruction").should be < text.index!("Turn 2 instruction")
+ text.should contain "Turn 1 outcome: rejected; rejected: op-1; reasons: op-1: wrong green"
+ text.should contain "Turn 2 outcome: not reviewed"
+ # the null fields and the why are not worth the tokens
+ text.should contain %("op":"set_attr")
+ text.should_not contain %("selector":null)
+ text.should_not contain "recolours Meeting 1.01"
+ end
+
+ it "caps the index, the subtrees and the history" do
+ big = JSON.parse(context.to_json)
+ index = big["index"].as_a
+ 3000.times do |n|
+ index << JSON.parse(%({"a":"x#{n}","id":null,"tag":"path","cls":"cls-1","label":null,"bbox":[#{n},#{n},10,10],"p":"e1","z":#{n},"kind":"art","prot":false,"h":"#{n.to_s.rjust(8, '0')}"}))
+ end
+ subtrees = big["subtrees"].as_h
+ 12.times { |n| subtrees["s#{n}"] = JSON::Any.new("") }
+
+ history = Array.new(10) do |n|
+ Llm::Prompt::Turn.new(instruction: "turn #{n}", ops: [] of JSON::Any)
+ end
+
+ request = Llm::Prompt.build(Llm::Prompt::Options.new(
+ context: big,
+ instruction: "x",
+ model: "gpt-5.4-mini",
+ history: history,
+ ))
+
+ index_text = Llm::Prompt.index_text(big)
+ index_text.bytesize.should be < Llm::Prompt::MAX_INDEX_BYTES + 2_000
+ index_text.should contain "more elements omitted"
+ # the front of the index survives, the tail goes
+ index_text.should contain "e4 | table-01.004"
+
+ subtree_text = Llm::Prompt.subtrees_text(big)
+ subtree_text.bytesize.should be < Llm::Prompt::MAX_SUBTREE_BYTES + 2_000
+ subtree_text.should contain "further subtrees omitted"
+
+ text = request.text
+ text.should_not contain "Turn 7 instruction"
+ text.should contain "turn 9"
+ text.should_not contain "turn 0"
+ end
+
+ it "leaves out sections the context does not carry" do
+ bare = JSON.parse(%({"schema":"placeos.map-ai-context/1","index":[]}))
+ request = Llm::Prompt.build(Llm::Prompt::Options.new(context: bare, instruction: "Nothing.", model: "m"))
+ request.parts.size.should eq 2
+ request.text.should_not contain "SUBTREES"
+ request.text.should_not contain "ANNOTATIONS"
+ end
+ end
+
+ describe Llm::Schema do
+ it "sends the vendor the editor's ops-schema.json as it is" do
+ source = JSON.parse(File.read(File.join(__DIR__, "../src/placeos-rest-api/utilities/llm/ops-schema.json")))
+ schema = Llm::Schema.openai
+ schema.should eq source
+
+ schema["type"].as_s.should eq "object"
+ schema["additionalProperties"].as_bool.should be_false
+ schema["required"].as_a.map(&.as_s).should eq ["schema_version", "summary", "ops", "questions"]
+ schema["properties"]["schema_version"]["enum"].as_a.map(&.as_i).should eq [Llm::Schema::VERSION]
+
+ variants = schema["properties"]["ops"]["items"]["anyOf"].as_a
+ variants.map(&.["properties"].["op"].["enum"].[0].as_s).should eq Llm::Schema::OPS
+ variants.each do |variant|
+ variant["additionalProperties"].as_bool.should be_false
+ variant["required"].as_a.map(&.as_s).should eq variant["properties"].as_h.keys
+ variant["properties"]["why"]["type"].as_s.should eq "string"
+ end
+
+ schema["$defs"]["target"]["required"].as_a.map(&.as_s).should eq ["a", "id", "h"]
+ schema["$defs"]["name_value"]["required"].as_a.map(&.as_s).should eq ["name", "value"]
+ # every object anywhere in the tree is closed and fully required
+ PlaceOS::Api.assert_strict_objects(schema)
+
+ request = Llm::Prompt.build(Llm::Prompt::Options.new(context: JSON.parse(%({"index":[]})), instruction: "x", model: "m"))
+ request.schema.should eq source
+ request.schema_name.should eq "map_edit"
+ end
+
+ it "derives the vocabulary from the schema" do
+ Llm::Schema::OPS.should eq %w(set_attr set_style_rule set_text transform delete duplicate insert_shape set_id reorder group ungroup replace_element replace_path no_change ask)
+ Llm::Schema::FIELDS_BY_OP["set_attr"].should eq %w(target name value)
+ Llm::Schema::FIELDS_BY_OP["set_style_rule"].should eq %w(selector declarations)
+ Llm::Schema::FIELDS_BY_OP["insert_shape"].should eq %w(parent after kind attrs new_id text)
+ Llm::Schema::SHAPES.should eq %w(rect circle ellipse line polygon polyline path text g)
+ Llm::Schema::POSITIONS.should eq %w(first last)
+ Llm::Schema.reference.should contain "duplicate: target, new_id, translate, count, id_pattern\n"
+ Llm::Schema.reference.should contain "ask: question\n"
+
+ Llm::Schema.valid_id?("table-01.018").should be_true
+ Llm::Schema.valid_id?("1abc").should be_false
+ Llm::Schema.valid_id?("ai desk").should be_false
+ Llm::Schema.allowed_attribute?("data-name").should be_true
+ Llm::Schema.allowed_attribute?("data-map-editor-anchor").should be_false
+ Llm::Schema.allowed_attribute?("data-").should be_false
+ Llm::Schema.allowed_attribute?("onclick").should be_false
+ Llm::Schema.allowed_attribute?("href").should be_false
+ Llm::Schema.allowed_attribute?("transform").should be_false
+ end
+ end
+
+ describe Llm::Values do
+ it "checks attribute values the way the editor does" do
+ Llm::Values.attribute_value_issue("fill", "#90ee90").should be_nil
+ Llm::Values.attribute_value_issue("fill", "lightblue").should be_nil
+ Llm::Values.attribute_value_issue("fill", "rgba(0, 0, 255, 0.45)").should be_nil
+ Llm::Values.attribute_value_issue("fill", "url(#grad) none").should be_nil
+ Llm::Values.attribute_value_issue("fill", "#f28c28.").should eq %("#f28c28." is not a colour)
+ Llm::Values.attribute_value_issue("fill", "#008000 garbage").should eq %("#008000 garbage" is not a colour)
+ Llm::Values.attribute_value_issue("stroke", "url(http://evil.example/p.svg#g)").should eq %("url(http://evil.example/p.svg#g)" is not a colour)
+ Llm::Values.attribute_value_issue("stroke-width", "1.5px").should be_nil
+ Llm::Values.attribute_value_issue("stroke-width", "thick").should eq %("thick" is not a length)
+ Llm::Values.attribute_value_issue("opacity", "0.6").should be_nil
+ Llm::Values.attribute_value_issue("opacity", "60%").should be_nil
+ Llm::Values.attribute_value_issue("opacity", "1.5").should eq %("1.5" is not an opacity between 0 and 1)
+ Llm::Values.attribute_value_issue("stroke-dasharray", "4 2").should be_nil
+ Llm::Values.attribute_value_issue("stroke-dasharray", "4 two").should eq %("4 two" is not a dash array)
+ Llm::Values.attribute_value_issue("class", "cls-12 desk").should be_nil
+ Llm::Values.attribute_value_issue("class", "12cls").should eq %("12cls" is not a class list)
+ Llm::Values.attribute_value_issue("visibility", "hidden").should be_nil
+ Llm::Values.attribute_value_issue("font-family", "Arial, sans-serif").should be_nil
+ Llm::Values.attribute_value_issue("font-family", "url(x)").should eq %("url(x)" is not a font family)
+ Llm::Values.attribute_value_issue("font-weight", "700").should be_nil
+ Llm::Values.attribute_value_issue("font-weight", "heavy").should eq %("heavy" is not a font weight)
+ Llm::Values.attribute_value_issue("text-anchor", "middle").should be_nil
+ Llm::Values.attribute_value_issue("data-name", "Boardroom").should be_nil
+ Llm::Values.attribute_value_issue("data-name", "x").should eq "data-name may not contain markup"
+ end
+
+ it "checks geometry and path data" do
+ Llm::Values.geometry_value_issue("x", "12.5").should be_nil
+ Llm::Values.geometry_value_issue("x", "12px").should eq %(x="12px" is not a number)
+ Llm::Values.geometry_value_issue("points", "0,0 10,0 10,10").should be_nil
+ Llm::Values.geometry_value_issue("points", "0,0 10").should eq %("0,0 10" is not a points list)
+ Llm::Values.path_data_issue("M100 80h120v90h-120z").should be_nil
+ Llm::Values.path_data_issue("M0 0 C1 1 2 2 3 3 Z").should be_nil
+ Llm::Values.path_data_issue("").should eq "empty path data"
+ Llm::Values.path_data_issue("L10 10").should eq "path must start with M"
+ Llm::Values.path_data_issue("M0 0 L10").should eq "command L has a partial argument group"
+ Llm::Values.path_data_issue("M0 0 X").should eq %(unexpected characters "X")
+ Llm::Values.path_data_issue("10 M0 0").should eq "number before the first command"
+ Llm::Values.path_data_issue("M0 0 Z 1").should eq "Z takes no arguments"
+ end
+
+ it "spots unsafe CSS, escapes included" do
+ Llm::Values.unsafe_css?("fill: #eee").should be_false
+ Llm::Values.unsafe_css?("fill: url(#grad)").should be_false
+ Llm::Values.unsafe_css?("fill: url(http://evil)").should be_true
+ Llm::Values.unsafe_css?("behavior: url(x.htc)").should be_true
+ Llm::Values.unsafe_css?("width: expression(1)").should be_true
+ Llm::Values.unsafe_css?("fill: u\\72l(http://evil)").should be_true
+ Llm::Values.external_url?("url(#grad)").should be_false
+ Llm::Values.external_url?("url( 'http://x' )").should be_true
+ end
+ end
+
+ describe Llm::Ops do
+ context = JSON.parse(File.read(File.join(__DIR__, "fixtures/map_ai/context.json")))
+
+ it "reads fenced JSON and refuses prose" do
+ envelope = Llm::Ops.parse("```json\n{\"schema_version\":1,\"summary\":\"s\",\"ops\":[],\"questions\":[\"which?\"]}\n```")
+ envelope.summary.should eq "s"
+ envelope.ops.should be_empty
+ envelope.questions.should eq ["which?"]
+
+ stringified = Llm::Ops.parse({
+ summary: "s",
+ ops: [{op: "delete", target: {a: "e2"}}].to_json,
+ questions: ["which?"].to_json,
+ }.to_json)
+ stringified.ops.size.should eq 1
+ stringified.ops[0]["op"].should eq "delete"
+ stringified.questions.should eq ["which?"]
+
+ expect_raises(Error::ImageGen::InvalidResponse, "did not answer with JSON") do
+ Llm::Ops.parse("I would change the fill of the boardroom.")
+ end
+ expect_raises(Error::ImageGen::InvalidResponse, "no ops list") do
+ Llm::Ops.parse(%({"summary":"s","ops":"not json"}))
+ end
+ expect_raises(Error::ImageGen::InvalidResponse, "no ops list") do
+ Llm::Ops.parse(%({"summary":"s"}))
+ end
+ expect_raises(Error::ImageGen::InvalidResponse, "empty") { Llm::Ops.parse("") }
+ end
+
+ # the fixture: e1 walls, e2 path area-1.01-status, e3 path area-1.02-status (protected),
+ # e4 rect table-01.004 (protected), e5 rect table-01.005, e6 text label-1.01, e7 path
+ it "flags the ops that fail a check, by id, and leaves every op as it came" do
+ cases = [
+ { %({"op":"set_attr","target":{"a":"e2","id":"area-1.01-status","h":"b2b2b2b2"},"name":"fill","value":"#90ee90","why":"w"}), nil },
+ { %({"op":"set_attr","target":{"a":"e2","id":"","h":""},"name":"fill","value":"#90ee90","why":"w"}), nil },
+ { %({"op":"set_attr","target":{"a":"","id":"area-1.01-status","h":"b2b2b2b2"},"name":"fill","value":"#90ee90","why":"w"}), nil },
+ { %({"op":"set_attr","target":{"a":"","id":"","h":""},"name":"fill","value":"#90ee90","why":"w"}), "target names neither an anchor nor an id" },
+ { %({"op":"set_attr","target":{"a":"e2","id":"","h":"stale"},"name":"fill","value":"#90ee90","why":"w"}), "the element changed since the index was built (hash mismatch)" },
+ { %({"op":"set_attr","target":{"a":"e99","id":null,"h":null},"name":"fill","value":"#90ee90","why":"w"}), "anchor e99 is not in the index" },
+ { %({"op":"set_attr","target":{"a":"e2","id":"table-01.004","h":null},"name":"fill","value":"#000","why":"w"}), "anchor e2 and id table-01.004 are different elements" },
+ { %({"op":"set_attr","target":{"a":"e2","id":null,"h":"stale"},"name":"fill","value":"#000","why":"w"}), "the element changed since the index was built (hash mismatch)" },
+ { %({"op":"set_attr","target":{"a":"e2","id":null,"h":null},"name":"onclick","value":"x","why":"w"}), "attribute onclick may not be set" },
+ { %({"op":"set_attr","target":{"a":"e2","id":null,"h":null},"name":"data-name","value":"Boardroom","why":"w"}), nil },
+ { %({"op":"set_attr","target":{"a":"e2","id":null,"h":null},"name":"data-map-editor-anchor","value":"e9","why":"w"}), "attribute data-map-editor-anchor may not be set" },
+ { %({"op":"set_attr","target":{"a":"e2","id":null,"h":null},"name":"visibility","value":"display:none","why":"w"}), %("display:none" is not a visibility keyword) },
+ { %({"op":"set_attr","target":{"a":"e2","id":null,"h":null},"name":"fill","value":"#f28c28.","why":"w"}), %("#f28c28." is not a colour) },
+ { %({"op":"set_attr","target":{"a":"e2","id":null,"h":null},"name":"fill","value":"url(http://evil.example/p.svg#g)","why":"w"}), %("url(http://evil.example/p.svg#g)" is not a colour) },
+ { %({"op":"set_attr","target":{"a":"e2","id":null,"h":null},"name":"fill","value":"url(#grad)","why":"w"}), nil },
+ { %({"op":"set_attr","target":{"a":"e2","id":null,"h":null},"name":"fill","value":null,"why":"w"}), nil },
+ { %({"op":"set_attr","target":{"a":"e2","id":null,"h":null},"name":"opacity","value":"1.5","why":"w"}), %("1.5" is not an opacity between 0 and 1) },
+ { %({"op":"set_text","target":{"a":"e2","id":null,"h":null},"text":"x","why":"w"}), "set_text only applies to text and tspan elements" },
+ { %({"op":"set_text","target":{"a":"e6","id":null,"h":null},"text":"Boardroom","why":"w"}), nil },
+ { %({"op":"transform","target":{"a":"e5","id":null,"h":null},"targets":null,"translate":[20,0],"scale":null,"rotate":null,"origin":null,"why":"w"}), nil },
+ { %({"op":"transform","target":{"a":"e5","id":null,"h":null},"targets":null,"translate":null,"scale":null,"rotate":null,"origin":null,"why":"w"}), "transform needs translate, scale or rotate" },
+ { %({"op":"transform","target":null,"targets":[{"a":"e4","id":null,"h":null},{"a":"e5","id":null,"h":null}],"translate":[20],"scale":null,"rotate":null,"origin":null,"why":"w"}), "translate must be [dx, dy]" },
+ { %({"op":"transform","target":{"a":"e5","id":null,"h":null},"targets":null,"translate":null,"scale":null,"rotate":"90","origin":null,"why":"w"}), "rotate must be a number" },
+ { %({"op":"transform","target":null,"targets":null,"translate":[1,1],"scale":null,"rotate":null,"origin":null,"why":"w"}), "no target given" },
+ { %({"op":"delete","target":{"a":"e4","id":null,"h":null},"targets":null,"why":"w"}), nil },
+ { %({"op":"delete","target":null,"targets":null,"why":"w"}), "no target given" },
+ { %({"op":"duplicate","target":{"a":"e5","id":null,"h":null},"new_id":"table-01.006","translate":[40,0],"count":2,"id_pattern":null,"why":"w"}), nil },
+ { %({"op":"duplicate","target":{"a":"e5","id":null,"h":null},"new_id":"table-01.004","translate":[40,0],"count":null,"id_pattern":null,"why":"w"}), "table-01.004 is already an id in the map" },
+ { %({"op":"duplicate","target":{"a":"e5","id":null,"h":null},"new_id":"1bad","translate":[40,0],"count":null,"id_pattern":null,"why":"w"}), %("1bad" is not a valid id) },
+ { %({"op":"duplicate","target":{"a":"e5","id":null,"h":null},"new_id":null,"translate":null,"count":null,"id_pattern":null,"why":"w"}), "duplicate needs translate" },
+ { %({"op":"duplicate","target":{"a":"e5","id":null,"h":null},"new_id":null,"translate":[40,0],"count":2.5,"id_pattern":null,"why":"w"}), "count must be a whole number" },
+ { %({"op":"duplicate","target":{"a":"e5","id":null,"h":null},"new_id":null,"translate":[40,0],"count":41,"id_pattern":null,"why":"w"}), "count must be between 1 and 40" },
+ { %({"op":"insert_shape","parent":{"a":"e1","id":null,"h":null},"after":null,"kind":"rect","attrs":[{"name":"x","value":"1"},{"name":"width","value":"30"},{"name":"fill","value":"#fff"}],"new_id":"ai-desk-1","text":null,"why":"w"}), nil },
+ { %({"op":"insert_shape","parent":{"a":"e1","id":null,"h":null},"after":null,"kind":"rect","attrs":[],"new_id":"ai-desk-1","text":null,"why":"w"}), "ai-desk-1 is used twice in this answer" },
+ { %({"op":"insert_shape","parent":{"a":"e1","id":null,"h":null},"after":null,"kind":"script","attrs":[],"new_id":null,"text":null,"why":"w"}), "script is not a shape that can be inserted" },
+ { %({"op":"insert_shape","parent":{"a":"e1","id":null,"h":null},"after":null,"kind":"rect","attrs":[{"name":"onload","value":"x"}],"new_id":null,"text":null,"why":"w"}), "attribute onload is not allowed on a rect" },
+ { %({"op":"insert_shape","parent":{"a":"e1","id":null,"h":null},"after":null,"kind":"rect","attrs":[{"name":"transform","value":"rotate(9)"}],"new_id":null,"text":null,"why":"w"}), "attribute transform is not allowed on a rect" },
+ { %({"op":"insert_shape","parent":{"a":"e1","id":null,"h":null},"after":null,"kind":"rect","attrs":[{"name":"x","value":"abc"}],"new_id":null,"text":null,"why":"w"}), %(x="abc" is not a number) },
+ { %({"op":"insert_shape","parent":{"a":"e1","id":null,"h":null},"after":null,"kind":"text","attrs":[{"name":"dx","value":"4"}],"new_id":null,"text":"Hi","why":"w"}), nil },
+ { %({"op":"insert_shape","parent":{"a":"e1","id":null,"h":null},"after":null,"kind":"path","attrs":[{"name":"d","value":"M0 0 L10 10 Z"}],"new_id":null,"text":null,"why":"w"}), nil },
+ { %({"op":"insert_shape","parent":{"a":"e1","id":null,"h":null},"after":null,"kind":"polygon","attrs":[{"name":"points","value":"0,0 10,0 10"}],"new_id":null,"text":null,"why":"w"}), %("0,0 10,0 10" is not a points list) },
+ { %({"op":"insert_shape","parent":{"a":"e99","id":null,"h":null},"after":null,"kind":"g","attrs":[],"new_id":null,"text":null,"why":"w"}), "parent: anchor e99 is not in the index" },
+ { %({"op":"set_style_rule","selector":".cls-12","declarations":[{"name":"fill","value":"#eee"},{"name":"stroke","value":"#333"}],"why":"w"}), nil },
+ { %({"op":"set_style_rule","selector":"*","declarations":[{"name":"fill","value":"#eee"}],"why":"w"}), %(the selector "*" is not allowed) },
+ { %({"op":"set_style_rule","selector":".a{} @import url(http://evil); .b","declarations":[{"name":"fill","value":"#eee"}],"why":"w"}), %(the selector ".a{} @import url(http://evil); .b" is not allowed) },
+ { %({"op":"set_style_rule","selector":".cls-12","declarations":[{"name":"background","value":"url(http://x)"}],"why":"w"}), "property background may not be set" },
+ { %({"op":"set_style_rule","selector":".cls-12","declarations":[{"name":"fill","value":"url(http://x)"}],"why":"w"}), "the value of fill is not allowed" },
+ { %({"op":"set_style_rule","selector":".cls-12","declarations":[{"name":"fill","value":"#eee } svg { visibility: hidden"}],"why":"w"}), %("#eee } svg { visibility: hidden" is not a colour) },
+ { %({"op":"set_style_rule","selector":".cls-12","declarations":[{"name":"fill","value":"u\\\\72l(http://evil)"}],"why":"w"}), "the value of fill is not allowed" },
+ { %({"op":"set_style_rule","selector":".cls-12","declarations":"fill: #eee","why":"w"}), "set_style_rule needs declarations" },
+ { %({"op":"set_style_rule","selector":".cls-12","declarations":[],"why":"w"}), "set_style_rule needs declarations" },
+ { %({"op":"set_id","target":{"a":"e5","id":null,"h":null},"new_id":"table-01.005-renamed","why":"w"}), nil },
+ { %({"op":"set_id","target":{"a":"e5","id":null,"h":null},"new_id":null,"why":"w"}), nil },
+ { %({"op":"reorder","target":{"a":"e5","id":null,"h":null},"before":null,"after":null,"position":"last","why":"w"}), nil },
+ { %({"op":"reorder","target":{"a":"e5","id":null,"h":null},"before":null,"after":null,"position":null,"why":"w"}), "reorder needs exactly one of before, after or position" },
+ { %({"op":"reorder","target":{"a":"e5","id":null,"h":null},"before":{"a":"e4","id":null,"h":null},"after":null,"position":"first","why":"w"}), "reorder needs exactly one of before, after or position" },
+ { %({"op":"group","targets":[{"a":"e4","id":null,"h":null},{"a":"e5","id":null,"h":null}],"new_id":"ai-desks","why":"w"}), nil },
+ { %({"op":"group","targets":[],"new_id":null,"why":"w"}), "group needs targets" },
+ { %({"op":"ungroup","target":{"a":"e5","id":null,"h":null},"why":"w"}), "ungroup only applies to a group" },
+ { %({"op":"ungroup","target":{"a":"e1","id":null,"h":null},"why":"w"}), nil },
+ { %({"op":"replace_element","target":{"a":"e2","id":null,"h":null},"markup":"","why":"w"}), nil },
+ { %({"op":"replace_element","target":{"a":"e2","id":null,"h":null},"markup":"","why":"w"}), "attribute onload is not allowed" },
+ { %({"op":"replace_element","target":{"a":"e2","id":null,"h":null},"markup":"","why":"w"}), "the markup must be exactly one element" },
+ { %({"op":"replace_element","target":{"a":"e2","id":null,"h":null},"markup":"","why":"w"}), "href must reference an id in the document" },
+ { %({"op":"replace_element","target":{"a":"e2","id":null,"h":null},"markup":"","why":"w"}), nil },
+ { %({"op":"replace_element","target":{"a":"e2","id":null,"h":null},"markup":"","why":"w"}), nil },
+ { %({"op":"replace_element","target":{"a":"e2","id":null,"h":null},"markup":"","why":"w"}), "href must reference an id in the document" },
+ { %({"op":"replace_element","target":{"a":"e2","id":null,"h":null},"markup":"","why":"w"}), "xlink:href must reference an id in the document" },
+ { %({"op":"replace_element","target":{"a":"e2","id":null,"h":null},"markup":"","why":"w"}), nil },
+ { %({"op":"replace_element","target":{"a":"e2","id":null,"h":null},"markup":"","why":"w"}), "style contains an unsafe value" },
+ { %({"op":"replace_element","target":{"a":"e2","id":null,"h":null},"markup":"","why":"w"}), "style contains an unsafe value" },
+ { %({"op":"replace_element","target":{"a":"e2","id":null,"h":null},"markup":"","why":"w"}), "fill may not reference anything outside the document" },
+ { %({"op":"replace_element","target":{"a":"e2","id":null,"h":null},"markup":"","why":"w"}), "