diff --git a/OPENAPI_DOC.yml b/OPENAPI_DOC.yml
index 0bdef13f..be46dc1b 100644
--- a/OPENAPI_DOC.yml
+++ b/OPENAPI_DOC.yml
@@ -47306,6 +47306,10 @@ components:
anyOf:
- type: object
- type: "null"
+ repair:
+ anyOf:
+ - type: object
+ - type: "null"
resolution:
anyOf:
- type: object
diff --git a/spec/controllers/map_ai_spec.cr b/spec/controllers/map_ai_spec.cr
index e0ab0270..63ce901f 100644
--- a/spec/controllers/map_ai_spec.cr
+++ b/spec/controllers/map_ai_spec.cr
@@ -372,6 +372,88 @@ module PlaceOS::Api
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"
+ # the repair round answered the same, so the first answer stands
+ final["repair"].should eq JSON.parse(%({"rejected":1,"remaining":1,"used":false}))
+ final["usage"]["attempts"].as_i.should eq 2
+ end
+
+ it "asks once more with the rejected ops and keeps the corrected answer" do
+ setup_signage_ai
+ _, headers = Spec::Authentication.authentication
+ level = map_ai_level
+ requests = [] of JSON::Any
+
+ first = {
+ 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
+ second = {
+ summary: "s2",
+ ops: [
+ {op: "set_attr", why: "w", target: {a: "e4", 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_sequence([first, second], 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["summary"].as_s.should eq "s2"
+ final["ops"].as_a.map(&.["target"]["a"].as_s).should eq ["e4", "e5"]
+ final["server_issues"].as_h.should be_empty
+ final["repair"].should eq JSON.parse(%({"rejected":1,"remaining":0,"used":true}))
+ final["usage"]["attempts"].as_i.should eq 2
+ final["cost_units"].as_f.should eq 2560.0
+
+ requests.size.should eq 2
+ asked_again = requests[1].to_json
+ asked_again.should contain "REJECTED"
+ asked_again.should contain "op-1: anchor e404 is not in the index"
+ end
+
+ it "keeps the first answer when the repair would drop ops the server accepted" do
+ setup_signage_ai
+ _, headers = Spec::Authentication.authentication
+ level = map_ai_level
+ requests = [] of JSON::Any
+
+ first = {
+ 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_sequence([first, {summary: "gave up", ops: [] of String, questions: [] of String}.to_json], 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["summary"].as_s.should eq "s"
+ final["ops"].as_a.size.should eq 2
+ final["repair"].should eq JSON.parse(%({"rejected":1,"remaining":0,"used":false}))
+ requests.size.should eq 2
+ end
+
+ it "does not ask again when every op passes" do
+ setup_signage_ai
+ _, headers = Spec::Authentication.authentication
+ level = map_ai_level
+ requests = [] of JSON::Any
+ HttpMocks.map_ai_vendor(MAP_AI_ANSWER, 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 "done"
+ final.as_h.has_key?("repair").should be_false
+ requests.size.should eq 1
end
it "records a refusal as a moderation failure" do
diff --git a/spec/llm_prompt_spec.cr b/spec/llm_prompt_spec.cr
index 97199ef4..ba21786d 100644
--- a/spec/llm_prompt_spec.cr
+++ b/spec/llm_prompt_spec.cr
@@ -110,6 +110,36 @@ module PlaceOS::Api
text.should_not contain "recolours Meeting 1.01"
end
+ it "asks again with the rejected operations and why, after the instruction" do
+ request = Llm::Prompt.build(Llm::Prompt::Options.new(
+ context: context,
+ instruction: "Label Meeting 1.01.",
+ model: "gpt-6.1-sol",
+ ))
+ envelope = Llm::Ops::Envelope.new(
+ summary: "Labelled Meeting 1.01.",
+ ops: [
+ JSON.parse(%({"op":"set_attr","target":{"a":"e2","id":null,"h":null},"name":"fill","value":"#90ee90","why":"w"})),
+ JSON.parse(%({"op":"insert_shape","parent":null,"after":{"a":"e2","id":null,"h":null},"kind":"text","attrs":[],"new_id":"ai-label","text":"1.01","why":"w"})),
+ ],
+ questions: [] of String,
+ )
+
+ repair = Llm::Prompt.repair(request, envelope, {"op-2" => "after must be a child of the parent"})
+
+ repair.parts.size.should eq request.parts.size + 1
+ repair.parts[0...request.parts.size].should eq request.parts
+ repair.system.should eq request.system
+ repair.max_output_tokens.should eq request.max_output_tokens
+ text = repair.text
+ text.index!("REPAIR\n").should be > text.index!("INSTRUCTION\n")
+ text.should contain "PREVIOUS ANSWER summary: Labelled Meeting 1.01."
+ text.should contain %("op_id":"op-1","op":"set_attr")
+ text.should contain %("op_id":"op-2","op":"insert_shape")
+ text.should_not contain %("parent":null)
+ text.should contain "REJECTED\nop-2: after must be a child of the parent\n"
+ end
+
it "caps the index, the subtrees and the history" do
big = JSON.parse(context.to_json)
index = big["index"].as_a
@@ -230,6 +260,8 @@ module PlaceOS::Api
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("dominant-baseline", "middle").should be_nil
+ Llm::Values.attribute_value_issue("dominant-baseline", "centre").should eq %("centre" is not a dominant baseline)
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
@@ -337,6 +369,9 @@ module PlaceOS::Api
{ %({"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":"insert_shape","parent":{"a":"e1","id":null,"h":null},"after":{"a":"e2","id":null,"h":null},"kind":"rect","attrs":[],"new_id":null,"text":null,"why":"w"}), nil },
+ { %({"op":"insert_shape","parent":null,"after":{"a":"e2","id":null,"h":null},"kind":"rect","attrs":[],"new_id":null,"text":null,"why":"w"}), "after must be a child of the parent" },
+ { %({"op":"insert_shape","parent":{"a":"e5","id":null,"h":null},"after":null,"kind":"rect","attrs":[],"new_id":null,"text":null,"why":"w"}), "parent must be the root or a , not " },
{ %({"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) },
@@ -466,6 +501,64 @@ module PlaceOS::Api
checked.issues["op-1"].should eq "ai-chair-2 is already an id in the map"
end
+ it "places an insert after a child of its parent, the root when none is named" do
+ tree = JSON.parse(%({"index":[
+ {"a":"e0","id":"Layer_1","tag":"svg","p":null},
+ {"a":"e1","id":"room_meeting","tag":"g","p":null},
+ {"a":"e2","id":"room_border_8","tag":"g","p":"e1"},
+ {"a":"e3","id":"space_room_7","tag":"rect","p":"e2"},
+ {"a":"e4","id":"table","tag":"rect","p":"e1"}
+ ]}))
+ insert = ->(parent : String?, after : String?) do
+ JSON.parse({
+ "op" => "insert_shape", "kind" => "text", "text" => "Sydney Room 1", "attrs" => [] of String, "new_id" => nil,
+ "parent" => parent.try { |anchor| {"a" => anchor} }, "after" => after.try { |anchor| {"a" => anchor} },
+ }.to_json)
+ end
+ ops = [
+ insert.call("e2", "e3"), # after a child of the parent
+ insert.call("e1", "e3"), # after a grandchild: the gpt-5.4-mini mistake
+ insert.call(nil, "e1"), # after a child of the root, no parent named
+ insert.call("e0", "e1"), # the root named as the parent
+ insert.call(nil, "e3"), # not a child of the root
+ insert.call("e3", nil), # a rect is no parent
+ insert.call("e1", nil),
+ ]
+ checked = Llm::Ops.check(ops, tree)
+ checked.issues.should eq({
+ "op-2" => "after must be a child of the parent",
+ "op-5" => "after must be a child of the parent",
+ "op-6" => "parent must be the root or a , not ",
+ })
+ end
+
+ it "leaves the after check to the editor once the batch deletes after or moves elements" do
+ tree = JSON.parse(%({"index":[
+ {"a":"e1","id":"room","tag":"g","p":null},
+ {"a":"e2","id":"border","tag":"g","p":"e1"},
+ {"a":"e3","id":"space","tag":"rect","p":"e2"},
+ {"a":"e4","id":"label","tag":"text","p":"e1"}
+ ]}))
+ deleted = [
+ %({"op":"delete","target":{"a":"e3"}}),
+ %({"op":"insert_shape","kind":"text","text":"x","attrs":[],"parent":{"a":"e1"},"after":{"a":"e3"}}),
+ %({"op":"insert_shape","kind":"text","text":"x","attrs":[],"parent":{"a":"e2"},"after":{"a":"e4"}}),
+ ].map { |op| JSON.parse(op) }
+ Llm::Ops.check(deleted, tree).issues.keys.should eq ["op-3"]
+
+ moved = [
+ %({"op":"group","targets":[{"a":"e3"},{"a":"e4"}],"new_id":"ai-group"}),
+ %({"op":"insert_shape","kind":"text","text":"x","attrs":[],"parent":{"a":"e2"},"after":{"a":"e4"}}),
+ ].map { |op| JSON.parse(op) }
+ Llm::Ops.check(moved, tree).issues.should be_empty
+
+ created = [
+ %({"op":"insert_shape","kind":"g","attrs":[],"new_id":"ai-wrap","parent":{"a":"e1"}}),
+ %({"op":"insert_shape","kind":"text","text":"x","attrs":[],"parent":{"id":"ai-wrap"},"after":{"a":"e4"}}),
+ ].map { |op| JSON.parse(op) }
+ Llm::Ops.check(created, tree).issues.should be_empty
+ end
+
it "renumbers rooms sequentially and reuses the freed id for a new room" do
rename_context = JSON.parse(%({"index":[{"a":"e1","id":"room-04","tag":"rect"},{"a":"e2","id":"room-05","tag":"rect"}]}))
ops = [
diff --git a/spec/spec_helpers/http_mocks.cr b/spec/spec_helpers/http_mocks.cr
index 643846fa..0e3fc50f 100644
--- a/spec/spec_helpers/http_mocks.cr
+++ b/spec/spec_helpers/http_mocks.cr
@@ -106,6 +106,22 @@ module PlaceOS::Api::HttpMocks
end
end
+ # `contents` answered in turn, the last one again once they run out.
+ def self.map_ai_vendor_sequence(contents : Array(String), requests : Array(JSON::Any))
+ WebMock
+ .stub(:post, CHAT_COMPLETIONS)
+ .to_return do |request|
+ requests << JSON.parse(WebMock.body(request).to_s)
+ content = contents[Math.min(requests.size, contents.size) - 1]
+ body = {
+ id: "chatcmpl-spec",
+ choices: [{message: {role: "assistant", content: content, refusal: nil}, finish_reason: "stop"}],
+ usage: {prompt_tokens: 1200, completion_tokens: 80, total_tokens: 1280},
+ }.to_json
+ HTTP::Client::Response.new(200, body, HTTP::Headers{"Content-Type" => "application/json"})
+ end
+ end
+
# The model declining to answer, which the adapter maps to `Moderated`.
def self.map_ai_vendor_refused
body = {
diff --git a/src/constants.cr b/src/constants.cr
index 14e23524..0f5b2d67 100644
--- a/src/constants.cr
+++ b/src/constants.cr
@@ -78,6 +78,9 @@ module PlaceOS::Api
# the second attempt's output budget after an answer cut off at MAP_AI_MAX_OUTPUT_TOKENS
MAP_AI_RETRY_OUTPUT_TOKENS = (ENV["MAP_AI_RETRY_OUTPUT_TOKENS"]? || "24000").to_i
+ # one more vendor call, inside the same job, when the server rejects some of an answer's ops
+ MAP_AI_REPAIR = ENV["MAP_AI_REPAIR"]?.try(&.downcase) != "false"
+
# default quotas in calls, overridden per provider row by map_user_per_day
# and map_domain_per_month
MAP_AI_USER_PER_DAY = (ENV["MAP_AI_USER_PER_DAY"]? || "40").to_i
diff --git a/src/placeos-rest-api/controllers/map_ai.cr b/src/placeos-rest-api/controllers/map_ai.cr
index 6c87a4df..a19c1e72 100644
--- a/src/placeos-rest-api/controllers/map_ai.cr
+++ b/src/placeos-rest-api/controllers/map_ai.cr
@@ -115,6 +115,10 @@ module PlaceOS::Api
getter server_issues : Hash(String, String)
getter questions : Array(JSON::Any)
getter usage : JSON::Any?
+ # when the server asked again about rejected ops: `rejected` before,
+ # `remaining` in the second answer (null when it was unusable), `used`
+ # when that answer replaced the first
+ getter repair : JSON::Any?
getter resolution : JSON::Any?
getter error_kind : String?
getter error_message : String?
@@ -145,6 +149,7 @@ module PlaceOS::Api
end
@questions = result["questions"]?.try(&.as_a?) || [] of JSON::Any
@usage = result["usage"]?
+ @repair = result["repair"]?
@resolution = result["resolution"]?
@error_kind = job.error_kind
@error_message = job.error_message
diff --git a/src/placeos-rest-api/utilities/llm/ops-schema.json b/src/placeos-rest-api/utilities/llm/ops-schema.json
index 791efac7..bdddc75a 100644
--- a/src/placeos-rest-api/utilities/llm/ops-schema.json
+++ b/src/placeos-rest-api/utilities/llm/ops-schema.json
@@ -33,7 +33,7 @@
},
"name": {
"type": "string",
- "description": "fill, stroke, stroke-width, stroke-dasharray, fill-opacity, stroke-opacity, opacity, class, visibility, rx, ry, font-size, font-family, font-weight, text-anchor or data-*"
+ "description": "fill, stroke, stroke-width, stroke-dasharray, fill-opacity, stroke-opacity, opacity, class, visibility, rx, ry, font-size, font-family, font-weight, text-anchor, dominant-baseline or data-*"
},
"value": {
"type": ["string", "null"],
diff --git a/src/placeos-rest-api/utilities/llm/ops.cr b/src/placeos-rest-api/utilities/llm/ops.cr
index 753acb34..13d9b41d 100644
--- a/src/placeos-rest-api/utilities/llm/ops.cr
+++ b/src/placeos-rest-api/utilities/llm/ops.cr
@@ -105,6 +105,7 @@ module PlaceOS::Api::Llm
elsif (hash = op.as_h?)
index.register_rename(hash, claimed)
index.register_created(hash)
+ index.register_structure(hash)
end
end
@@ -118,6 +119,10 @@ module PlaceOS::Api::Llm
getter protected_ids : Set(String)
# The map's viewBox as x, y, width, height; nil when the context has none.
getter view_box : Array(Float64)?
+ # anchors an earlier op in the batch deletes; an insert placed after one goes where it was
+ getter deleted = Set(String).new
+ # set once an earlier op in the batch can move elements to another parent
+ getter? restructured = false
def initialize(context : JSON::Any)
@anchors = {} of String => Hash(String, JSON::Any)
@@ -203,6 +208,41 @@ module PlaceOS::Api::Llm
def known_id?(id : String) : Bool
@ids.has_key?(id)
end
+
+ # Records what an accepted op does to the tree that later insert_shape
+ # checks rely on.
+ def register_structure(op : Hash(String, JSON::Any)) : Nil
+ case op["op"]?.try(&.as_s?)
+ when "delete"
+ targets = op["targets"]?.try(&.as_a?) || [] of JSON::Any
+ ([op["target"]?] + targets).each do |target|
+ entry, _ = resolve(target)
+ if (anchor = entry.try(&.["a"]?).try(&.as_s?))
+ @deleted << anchor
+ end
+ end
+ when "reorder", "group", "ungroup", "replace_element"
+ @restructured = true
+ end
+ end
+
+ # The editor's rule for insert_shape: `after` is a direct child of
+ # `parent`, the root when none is named. Nil when it holds or cannot be
+ # judged from the index: `after` or `parent` made in this batch, `after`
+ # deleted earlier in it, or the tree restructured by an earlier op.
+ def sibling_issue(parent : Hash(String, JSON::Any)?, after : Hash(String, JSON::Any)) : String?
+ return nil if restructured?
+ return nil unless after.has_key?("p")
+ anchor = after["a"]?.try(&.as_s?)
+ return nil if anchor && @deleted.includes?(anchor)
+
+ expected = nil
+ if parent && parent["tag"]?.try(&.as_s?) != "svg"
+ expected = parent["a"]?.try(&.as_s?)
+ return nil unless expected
+ end
+ after["p"]?.try(&.as_s?) == expected ? nil : "after must be a child of the parent"
+ end
end
# The editor's `canvasExpansionIssue`: the new bounds keep the whole old
@@ -290,13 +330,20 @@ module PlaceOS::Api::Llm
shape = op["kind"]?.try(&.as_s?)
return "insert_shape needs a kind" unless shape
return "#{shape} is not a shape that can be inserted" unless Schema::SHAPES.includes?(shape)
+ parent = nil
if present?(op["parent"]?)
- _, issue = index.resolve(op["parent"])
+ parent, issue = index.resolve(op["parent"])
return "parent: #{issue}" if issue
end
+ if (tag = parent.try(&.["tag"]?).try(&.as_s?)) && !{"g", "svg"}.includes?(tag)
+ return "parent must be the root or a , not <#{tag}>"
+ end
if present?(op["after"]?)
- _, issue = index.resolve(op["after"])
+ after, issue = index.resolve(op["after"])
return "after: #{issue}" if issue
+ if after && (misplaced = index.sibling_issue(parent, after))
+ return misplaced
+ end
end
geometry = Schema::SHAPE_GEOMETRY_ATTRIBUTES[shape]
(op["attrs"]?.try(&.as_a?) || [] of JSON::Any).each do |attr|
diff --git a/src/placeos-rest-api/utilities/llm/prompt.cr b/src/placeos-rest-api/utilities/llm/prompt.cr
index 00f4fd2c..351f56cd 100644
--- a/src/placeos-rest-api/utilities/llm/prompt.cr
+++ b/src/placeos-rest-api/utilities/llm/prompt.cr
@@ -285,6 +285,25 @@ module PlaceOS::Api::Llm
parts.join(' ')
end
+ # `request` again with its answer's rejected operations and the reasons,
+ # asking for the whole proposal corrected. Nothing from the answer was
+ # applied, so the map in the request is still the current state.
+ def self.repair(request : Request, envelope : Ops::Envelope, issues : Hash(String, String)) : Request
+ answered = envelope.ops.map_with_index do |op, position|
+ {"op_id" => JSON::Any.new(Ops.id_for(position))}.merge(compact_op(op))
+ end
+ text = String.build do |io|
+ io << "REPAIR\n"
+ io << "Your previous answer to this request has not been applied: the application rejected the operations listed under REJECTED, so the map is exactly as described above. "
+ io << "Answer again with the whole corrected proposal. Keep every operation that was not rejected as it was, fix each rejected operation or leave it out, and add nothing else.\n"
+ io << "PREVIOUS ANSWER summary: " << envelope.summary << '\n' if envelope.summary.presence
+ io << "PREVIOUS ANSWER ops: " << answered.to_json << '\n'
+ io << "REJECTED\n"
+ issues.each { |id, reason| io << id << ": " << reason << '\n' }
+ end
+ request.copy_with(parts: request.parts + [TextPart.new(text)] of Part)
+ end
+
# Earlier turns of this edit: the instruction, the ops answered and what the
# person did with them.
def self.history_text(history : Array(Turn)) : String
diff --git a/src/placeos-rest-api/utilities/llm/runner.cr b/src/placeos-rest-api/utilities/llm/runner.cr
index acd19830..a7a0d13e 100644
--- a/src/placeos-rest-api/utilities/llm/runner.cr
+++ b/src/placeos-rest-api/utilities/llm/runner.cr
@@ -4,7 +4,8 @@ require "placeos-models/upload"
module PlaceOS::Api::Llm
# The body of the spawned fiber for a map edit: the vendor call under the
# slot the request handler reserved, made once more at a larger output
- # budget when the first answer was cut off.
+ # budget when the first answer was cut off, and once more with the reasons
+ # when the server rejects some of the answer's operations.
#
# Everything it needs is captured before `spawn`: inside the fiber there is
# no request and `Log.context` is empty. The context the editor posted is
@@ -60,11 +61,13 @@ module PlaceOS::Api::Llm
reasoning_effort: context.adapter.default_effort,
))
- answer = context.adapter.complete(request)
+ asked = request
+ answer = context.adapter.complete(asked)
results << answer
if answer.finish == "length" && !cancelled?(context.job_id)
- answer = context.adapter.complete(request.copy_with(max_output_tokens: MAP_AI_RETRY_OUTPUT_TOKENS))
+ asked = request.copy_with(max_output_tokens: MAP_AI_RETRY_OUTPUT_TOKENS)
+ answer = context.adapter.complete(asked)
results << answer
end
@@ -75,6 +78,11 @@ module PlaceOS::Api::Llm
envelope = Ops.parse(answer.text)
checked = Ops.check(envelope.ops, context.context)
+ repair = nil
+ if MAP_AI_REPAIR && !checked.issues.empty? && !cancelled?(context.job_id)
+ envelope, checked, repair = repaired(context, asked, envelope, checked, results)
+ end
+
issues = {} of String => JSON::Any
checked.issues.each { |id, reason| issues[id] = JSON::Any.new(reason) }
@@ -86,6 +94,7 @@ module PlaceOS::Api::Llm
"questions" => JSON::Any.new(envelope.questions.map { |question| JSON::Any.new(question) }),
"usage" => JSON::Any.new(usage_of(results, context.model)),
}
+ written["repair"] = JSON::Any.new(repair) if repair
rescue ex
Log.warn(exception: ex) { "map AI vendor call failed" }
failure = ex
@@ -104,6 +113,45 @@ module PlaceOS::Api::Llm
give_back(held)
end
+ # The repair round: `request` again with the rejected ops and why. Its
+ # answer replaces the first only when it is whole, parses, keeps at least
+ # as many ops the server accepts, and has fewer rejected. Any failure of
+ # this call keeps the first answer.
+ private def self.repaired(
+ context : Context,
+ request : Request,
+ envelope : Ops::Envelope,
+ checked : Ops::Checked,
+ results : Array(Result),
+ ) : Tuple(Ops::Envelope, Ops::Checked, Hash(String, JSON::Any))
+ rejected = checked.issues.size
+ accepted = envelope.ops.size - rejected
+ remaining = nil
+ begin
+ answer = context.adapter.complete(Prompt.repair(request, envelope, checked.issues))
+ results << answer
+ if answer.finish != "length"
+ candidate = Ops.parse(answer.text)
+ rechecked = Ops.check(candidate.ops, context.context)
+ remaining = rechecked.issues.size
+ if remaining < rejected && candidate.ops.size - remaining >= accepted
+ return {candidate, rechecked, repair_note(rejected, remaining, true)}
+ end
+ end
+ rescue ex
+ Log.warn(exception: ex) { "map AI repair call failed, keeping the first answer" }
+ end
+ {envelope, checked, repair_note(rejected, remaining, false)}
+ end
+
+ private def self.repair_note(rejected : Int32, remaining : Int32?, used : Bool) : Hash(String, JSON::Any)
+ {
+ "rejected" => JSON::Any.new(rejected.to_i64),
+ "remaining" => JSON::Any.new(remaining.try(&.to_i64)),
+ "used" => JSON::Any.new(used),
+ }
+ end
+
# Releases the run's slot if it still holds it; returns false, the new state.
private def self.give_back(held : Bool?) : Bool
Llm.slots.release if held
diff --git a/src/placeos-rest-api/utilities/llm/schema.cr b/src/placeos-rest-api/utilities/llm/schema.cr
index d3960826..6bd54b66 100644
--- a/src/placeos-rest-api/utilities/llm/schema.cr
+++ b/src/placeos-rest-api/utilities/llm/schema.cr
@@ -27,7 +27,8 @@ module PlaceOS::Api::Llm
# attributes set_attr may touch, plus data-*; style rules take the same list
ATTRIBUTES = %w(fill stroke stroke-width stroke-dasharray fill-opacity stroke-opacity
- opacity class visibility rx ry font-size font-family font-weight text-anchor)
+ opacity class visibility rx ry font-size font-family font-weight text-anchor
+ dominant-baseline)
DATA_ATTRIBUTE = /\Adata-[a-z][a-z0-9_-]*\z/i
diff --git a/src/placeos-rest-api/utilities/llm/values.cr b/src/placeos-rest-api/utilities/llm/values.cr
index aa376238..43d04df1 100644
--- a/src/placeos-rest-api/utilities/llm/values.cr
+++ b/src/placeos-rest-api/utilities/llm/values.cr
@@ -39,6 +39,10 @@ module PlaceOS::Api::Llm
FONT_WEIGHT = /\A(?:normal|bold|bolder|lighter|[1-9]00)\z/
UNSAFE_TEXT = /url\(|expression\(|behavior:|javascript:|<|>|[\r\n]/i
+ # `dominant-baseline` keywords from SVG 2, plus the two SVG 1.1 edge keywords browsers still honour
+ DOMINANT_BASELINES = %w(auto alphabetic central hanging ideographic mathematical middle
+ text-after-edge text-before-edge text-bottom text-top)
+
# `sanitise.ts` UNSAFE_CSS_VALUE plus a backslash: browsers decode CSS
# escapes (`u\72l(`), so escaped text cannot be matched literally
UNSAFE_CSS = /expression\s*\(|behavior\s*:|javascript\s*:|url\s*\(\s*(?!['"]?#)|\\/i
@@ -115,6 +119,8 @@ module PlaceOS::Api::Llm
FONT_WEIGHT.match(value.strip) ? nil : "#{value.inspect} is not a font weight"
when "text-anchor"
%w(start middle end).includes?(value.strip) ? nil : "#{value.inspect} is not a text anchor"
+ when "dominant-baseline"
+ DOMINANT_BASELINES.includes?(value.strip) ? nil : "#{value.inspect} is not a dominant baseline"
else
if name.starts_with?("data-") && value =~ /[<>]/
"#{name} may not contain markup"