Skip to content

feat(map-ai): generate SVG edit proposals for Concierge (PPT-2832) - #453

Draft
camreeves wants to merge 29 commits into
masterfrom
PPT-2832-ai-map-review
Draft

camreeves wants to merge 29 commits into
masterfrom
PPT-2832-ai-map-review

Conversation

@camreeves

@camreeves camreeves commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Add maps/ai endpoints for capability discovery, edit jobs, polling and usage. The server sends bounded SVG context to OpenAI-compatible or Anthropic text models and returns validated edit operations; the browser applies them as a reviewable preview.

Reuse provider credentials and job storage with separate map quotas. Support Anthropic workspace headers and tool-input proposals. Validate references to newly created elements and counted duplicate IDs. Prompt guidance covers readable labels, collision avoidance and furniture grouping.

Validation: 57 focused adapter/prompt/controller specs pass; five distinct live browser prompts and two corrective retries completed without final validation issues. API documentation regenerated; final container image builds successfully. The review branch pins the companion models branch and uses its migrations in the spec harness; remove that temporary pin after the models change lands.

Draft for review and further end-to-end testing. No merge or deployment requested.

Companion frontend: PlaceOS/user-interfaces#570

Companion drafts: Frontend #570, API #453, Models #334.

camreeves and others added 27 commits October 12, 2026 19:33
PPT-2833. AI map editing reuses the signage AI provider rows and job table
with a text/vision adapter, so the pieces both adapter families need move
out of ImageGen::Adapter: credentials, credential and vendor_message become
Utils::VendorCredentials, and the job long poll and same-domain job lookup
become Utils::AIJobs so the map AI controller can include them rather than
copy them. SignageAI's behaviour is unchanged.

The spec helpers setup_signage_ai, signage_group and await_signage_ai_job
move into spec/spec_helpers so a second controller spec can use them without
requiring the signage spec file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
PPT-2833. The AI map editor spends vendor calls on behalf of whoever may
save the level's map, so the zone update gate is extracted into
Utils::GroupPermissions#zone_updatable?: support or admin, the legacy
org-zone ACL, or a support-subsystem Update on the zone. The broad signage
subsystem grant that Zones#update also honours is left out of it.

Zones#check_update_permissions keeps its behaviour exactly; its private
record-form legacy check now delegates to the shared zone_legacy_access?.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
PPT-2833. A text/vision call against a signage AI provider row for map
editing. The editor posts the context it built from the level's SVG (element
index, shortlisted subtrees, annotations, a marked up snapshot by upload id)
and the model answers with a closed vocabulary of edit operations that the
editor applies itself; nothing here reads or writes the map.

- Llm::Schema defines the vocabulary once and renders it in the OpenAI strict
  dialect; the TypeScript side in libs/map-editor mirrors it field for field.
- Llm::Prompt orders the request so the stable front (rules, op reference,
  index, subtrees) is identical across the turns of one edit and the
  instruction comes last, with byte caps per section and the parent chain's
  instruction, ops and resolution for a refine.
- Llm::Ops parses the answer (markdown fence tolerated) and checks each op
  against the posted index; failing ops are kept with server_issue so the
  editor can show them unchecked with the reason.
- Adapters::OpenAIChat posts chat completions with response_format
  json_schema strict, max_completion_tokens and reasoning_effort (dropped and
  resent on a 400 naming it), for OpenAI and both Azure URL styles; refusal
  and content_filter read as Moderated, 429 as Busy, IO::TimeoutError as
  Error::ImageGen::Timeout. No temperature: the gpt-5 family rejects it.
- Llm::Runner is the one-call version of the image runner: Running, markup
  fetch, prompt, one vendor call under one Llm slot (sized by
  MAP_AI_MAX_CALLS, apart from the image slots), result written with
  bump_version, cost_units = total tokens, invalid_response for an answer
  that is not JSON or was cut off.
- Error::ImageGen::InvalidResponse (502) and the MAP_AI_* constants.
- The markup snapshot tag ai-map-markup, reaped by the existing sweep.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
PPT-2833. /api/engine/v2/maps/ai: capabilities (providers that can edit
maps, the default, the map quota, the limits), edit (202, the job), jobs/:id
long poll, jobs index (kind, mine, zone_id), cancel, resolve (what the person
did with the answer, written into result.resolution), usage (support, map
edits only).

The edit gate is "may update this zone": support or admin, the legacy
org-zone ACL, or a support-subsystem Update, without the broad signage grant.
A non-support caller sees 404 for a zone outside the authority's org zone
tree. Quotas are counted by kind against map_user_per_day and
map_domain_per_month (defaults 40 and 1000). The context is posted inline
under MAP_AI_MAX_CONTEXT_BYTES; the row keeps its size and sha256, never the
body. The markup upload must be the caller's own png, jpeg or webp under the
image size cap, and is tagged for the sweep when it was uploaded for this
request.

Provider rows gain text_model (create, update, clear) and POST /:id/test
with kind=text runs a five-token chat completion under an Llm slot. The
signage jobs index takes a kind filter defaulting to the image kinds, so
map jobs stay out of "recent generations".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
PPT-2833. Chat completions stubs keyed on the /v1/chat/completions path so
the image stub (which matches every api.openai.com POST, and WebMock uses
the first registered match) cannot swallow them: a verbatim answer with the
requests it received, a refusal, a timeout, prose, and a 400 naming
reasoning_effort followed by the answer.

map_ai_spec covers capabilities (no provider, disabled, Vertex only, on with
the text model and the map quota), the edit path end to end with what
reached the vendor, the markup snapshot as an image part in the right
position, fenced JSON, prose and a cut-off answer as invalid_response, a
flagged unknown anchor, refusal, timeout, the reasoning_effort retry, the
map quota by kind, the read-only scope, the concierge org-zone ACL, a
caller who cannot update the zone, the broad signage grant, a zone outside
the organisation, the context caps, idempotency replay, the parent chain in
the prompt, an image job as parent, the jobs index and its kind filter on
both controllers, long poll and cancel, resolve, and usage by kind.
llm_prompt_spec covers the message order, the caps, the parent chain, the
strict rendering of the schema, and every check in Llm::Ops.

The storage GET stub now answers with a body_io: WebMock hands a stubbed
response to Http.get_bytes's streaming block as-is, so with a body string
Store.fetch always failed under WebMock and no spec had proven the source,
reference or markup fetch path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
PPT-2833. `crystal build ./src/app.cr && ./app -d > OPENAPI_DOC.yml`, run
locally against the models branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The vendor is now sent libs/map-editor's ops-schema.json as it is: one
anyOf variant per op, set_style_rule.declarations and insert_shape.attrs
as {name, value} arrays, schema_version in the envelope, $defs for target
and name_value. The Crystal rendering that was sent before was a flat
26-field object with declarations as a string, so nothing the model
returned could pass the editor's own validator. The file is embedded at
compile time and the vocabulary (ops, fields, shapes, positions) is read
from it, so the two copies cannot drift apart silently; the spec asserts
the rendering against the file. Both copies carry sha256
ff41e284fc4e145587baa7227f60e01b16dfc08ca59db14ff5184385f0b2bbd5.

Ops are stored exactly as the model returned them. The server's
bookkeeping moves off the op: result.server_issues maps op-N (N the
1-based position) to the reason, because the editor's schemaIssues
rejects any key outside the variant and the plan's inline server_issue
and id would have locked every row.

The checks follow the editor's rules: attribute, style and geometry
values are syntax-checked (value-syntax.ts ported, named colours
included), declarations are arrays, insert_shape attributes are per
kind with transform refused, the deny-list for replacement markup is
sanitise.ts's list plus <style> and <use>, <a> and data: images are
allowed, url(#fragment) is allowed, every element must be in the SVG
namespace, a backslash in CSS text is refused since browsers decode
escapes past a literal match, a transform with every field null is
refused, duplicate needs translate and a whole count, and a new id used
twice in one answer is flagged.

A finish_reason of length is retried once at MAP_AI_RETRY_OUTPUT_TOKENS
before the job fails as invalid_response; tokens are summed over the
attempts and usage carries the attempt count.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… by kind

JobResponse carries server_issues beside the untouched ops. The parent
chain is read before the slot is reserved and the row written, so a
failure there can no longer leave a QUEUED row the stale sweep never
touches with a process-local slot held. An idempotency key already used
by an image job answers 409 instead of replaying that image job.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The generate/edit gate and remaining_quota summed every kind, so map
edits consumed the image allowance.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A length finish with empty content, which a reasoning model produces
when the budget is spent before the answer, reported ok. The probe now
sends the same low reasoning effort as an edit, allows 128 output tokens
and reports invalid_response when nothing came back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The prompt tells the model that a room or desk name in the instruction
identifies the element whose id carries its number, that outlined labels
are glyph elements inside the room they name, how to rename one (delete
the glyphs, insert a text element), and to act on the best-supported
reading instead of asking whenever an element carries the number. The
index gains fill and inside columns, read from the context's fill and in
fields; the fixture carries both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The prompt prints a LABELS section from the context's labels list (the
element each label sits in, its glyph anchors in reading order, box and
fill) and tells the model to rename an outlined label by deleting those
anchors and inserting a text element sized from the label's box.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The prompt asks for the new text after the label's last glyph, y at the
first line's baseline, and lines separated by newline characters, which
the editor lays out as rows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The index gains a piece column from the context's piece field, and the
prompt tells the model that elements sharing a piece are the layers of one
object, to be deleted, moved or copied together.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d placement rules

Adds an Anthropic Messages adapter: structured output through
output_config.format, the effort from MAP_AI_ANTHROPIC_EFFORT, and
fallbacks: "default" for the models whose classifiers may decline a
request; a model that rejects either parameter is asked again without it.
A refusal is a moderation failure, 429 and overloaded are busy.

Capabilities now list the text models each provider is known to serve so
the editor can offer a choice, and the job usage records the model that
answered when the vendor says.

The prompt gains the thirds of the map as regions, text labels with their
words, outlined labels outside every room, and rules for renaming by
words, extending a desk run, making a room bookable and an honest summary.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Support API keys that require an explicit workspace and cover requests with and without a workspace ID.

Co-authored-by: Codex <noreply@openai.com>
Anthropic rejects the nullable reorder-position enum. Send equivalent typed alternatives while preserving the shared editor and OpenAI schema. Cover the conversion and update stale prompt-index expectations for the existing context columns.

Co-authored-by: Codex <noreply@openai.com>
Deduplicate nullable schemas through shared definitions to stay within the structured output union limit while preserving required fields and allowed values.

Co-authored-by: Codex <noreply@openai.com>
Use strings for all target fields; empty strings already mean absent to both validators. Removing nested nullable target combinations reduces the compiled output grammar while retaining anchor, ID and hash validation.

Co-authored-by: Codex <noreply@openai.com>
The operation grammar exceeds Anthropic structured-output compilation limits even with shared nullable definitions. Use the full operation schema as a non-strict client tool, request one proposal and validate its tool input before the existing operation checks. Automatic tool choice supports Fable 5.1 and Opus 5.5, which reject forced tool selection.

Co-authored-by: Codex <noreply@openai.com>
Require readable labels after background edits and duplicate furniture within its destination group, rotating complete pieces as needed.

Co-Authored-By: Codex <noreply@openai.com>
@github-actions github-actions Bot added the type: enhancement new feature or request label Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: enhancement new feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant