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"}), "