Skip to content

AI: Powered Templates — generate a list from a description - #174

Merged
Adron merged 2 commits into
mainfrom
issue-14-powered-templates
Sep 24, 2026
Merged

Adron merged 2 commits into
mainfrom
issue-14-powered-templates

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Stack

Fourth of five stacked PRs on the AI epic. Based on #173, not main:

PR Branch Base
#137 (open) issue-9-ai-service main
#173 issue-10-ai-gating issue-9-ai-service
this issue-14-powered-templates issue-10-ai-gating
next issue-15-powered-document issue-14-powered-templates

Merge in that order. Set --base accordingly when retargeting.

Hosting: one line, zero code-behind

Views/ListsView.xaml is contended by #161/#163/#164, so this follows the #55/#56 pattern. The entire diff to the contended file is:

<local:PoweredTemplatePanel OpenListCommand="{Binding SelectListCommand}" RefreshCommand="{Binding LoadListsCommand}" Margin="0,0,0,12"/>

dropped as the first child of the right pane's ScrollViewer StackPanel. No code-behind edit, no new resource, no visibility rule — the control collapses itself when #10's IsAiAvailable gate is closed, so a free account sees nothing at all, and it reads the two host commands through dependency properties. If that one line conflicts, drop it anywhere inside a StackPanel whose DataContext is ListsViewModel; nothing else about the control cares where it sits.

The list artifact, live-verified for the first time

#137's own doc comment records the list shape as contract-transcribed and never exercised. One /suggest call settled it:

POST /api/ai/suggest · {"feature":"powered_template","input":"a reading list with title, author, status, and rating"} → 200

{ "ok": true, "feature": "powered_template",
  "artifact": {
    "kind": "list",
    "title": "Reading List",
    "description": "Track books to read, currently reading, and finished, with ratings.",
    "dsl": {
      "name": "Reading List",
      "description": "A personal list to track books, their authors, reading status, and your rating.",
      "fields": [
        { "key":"title",  "type":"text",   "label":"Title",        "required":true,  "displayOrder":0 },
        { "key":"author", "type":"text",   "label":"Author",       "required":true,  "displayOrder":1 },
        { "key":"status", "type":"select", "label":"Status",        "required":true,  "displayOrder":2,
          "options":["To Read","Reading","Finished","Abandoned"], "defaultValue":"To Read" },
        { "key":"rating", "type":"number", "label":"Rating (1-5)", "required":false, "displayOrder":3,
          "visibility":{ "condition":{ "field":"status","operator":"equals","value":"Finished" } } }
      ]
    },
    "rows": [
      { "title":"The Hobbit",        "author":"J.R.R. Tolkien", "status":"Finished",  "rating":5 },
      { "title":"Project Hail Mary", "author":"Andy Weir",      "status":"Reading",   "rating":null },
      { "title":"Atomic Habits",     "author":"James Clear",    "status":"To Read",   "rating":null },
      { "title":"Dune",              "author":"Frank Herbert",  "status":"Abandoned", "rating":2 }
    ]
  },
  "usage": { "inputTokens":545, "outputTokens":713, "model":"claude-sonnet-5" },
  "quota": { "usedToday":4, "dailyLimit":50 } }

Things that were on record nowhere in this repo before this call: the dsl wrapper's own name/description, and the per-field displayOrder, options, defaultValue and visibility.condition. Four unannounced members on the very first call is the whole argument for AiListDsl being a deliberately partial projection — it parses the five members the column editor needs and round-trips every other member byte-for-byte out of a retained raw element. Renaming a label must not silently drop a conditional-visibility rule the model wrote, and /generate re-validates the DSL server-side, so returning exactly what came out (minus the deliberate edits) is the only safe default. Note also that rows are flat maps keyed by field key, and null is a legal cell value.

The round-trip is verified, not assumed

A scratch harness replays the real /suggest body through AiListDsl + AiListArtifactPayload, drops a column, renames a label, flips a required, and asserts 20 properties of the output. All pass, including the ones that would otherwise be silent data loss:

PASS  UNPARSED visibility.condition survived the edit
PASS  UNPARSED options[] survived
PASS  UNPARSED defaultValue survived
PASS  displayOrder re-numbered 0..n with no gap
PASS  untouched numeric cell stayed a JSON number
PASS  untouched null cell stayed null
PASS  row no longer carries the dropped column

That last group matters because the preview must show values as text (a TextBox holds a string), and re-serializing "5" is a guess about whether it was 5, 5.0 or "5". So a cell the user didn't touch returns its original JsonElement verbatim, and only an edited cell is re-typed from its column type — falling back to a string rather than discarding input it can't parse as a number.

AiListArtifactPayload exists alongside the read-side AiListArtifact because they face opposite directions: that type's cells are already-parsed JsonElements from a response, whereas cells a user just typed are strings the client types per column. Forcing those back through JsonElement would mean serializing and re-parsing each cell individually purely to satisfy the read type.

Quota discipline

/suggest and /generate are two units, so both are pre-flighted:

  • Suggest: the 300-word powered_template cap, mirrored, with a live word counter that goes amber before the button is pressed.
  • Confirm: non-empty title, ≥1 column, the documented 20-field ceiling, unique keys, and the documented ^[a-z][a-z0-9_-]*$ key rule. User-added columns are slugified into that shape locally — learning a key was illegal from a failed /generate costs a unit.
  • Field keys are deliberately not editable. Rows address cells by key, so retyping one would silently orphan that column's data. Labels are what people actually want to change.
  • Type pickers offer only types observed from the server (text, number, select) plus whatever types the artifact in hand already uses. Inventing a DSL type is a guess the server bills a unit to refuse.
  • No auto-retry anywhere — inherited from AI: status, subscriber gating and daily-quota surfacing #10's single RunAiAsync funnel.

Confirm treats /generate's {listId} as a hint, not a fact, and re-fetches with GET /api/lists/{id} before handing the host anything. If no id comes back it refreshes the host's browser and says plainly that a retry would duplicate the list and spend another credit, rather than asserting either outcome.

Quota spent and what's unverified

/suggest calls: 1. Units spent by this PR: 1 (usedToday 3 → 4).

  • POST /api/ai/generate was NOT called — it persists to a shared account. So the confirm path's response handling is reasoned from AI: add InterlinedApiClient.Ai.cs service + artifact models #137's contract transcription, not observed. It is written defensively for exactly that reason (read-after-write, and an explicit "don't retry, you'd duplicate" message when the id is absent). Whoever first runs this against a real account should confirm the {listId} field name and tighten AiCreatedResources.
  • context.templateKey is not surfaced. It's modelled in AI: add InterlinedApiClient.Ai.cs service + artifact models #137 (AiContext.PoweredTemplate) but "which base template to personalize" needs a list of valid keys, and no endpoint enumerating them was found. Plain descriptions work, so that's the supported path.
  • The 20-field ceiling and the key regex are documented, not probed — both are enforced client-side, so a wrong mirror would show a false rejection rather than spend a unit, which is the safe direction to be wrong in.
  • No select column options editor. options/defaultValue round-trip untouched and the column row displays the choices, but editing them would mean committing to the parts of the DSL that are still guesswork. Flagged rather than guessed.

Builds clean in both -c Debug and -c Release.

Closes #14

🤖 Generated with Claude Code

Describe a list in plain language, get a proposed schema plus starter rows,
edit both, then confirm it into a real list. The web app's Powered Templates
tab, as a self-contained card in the new-list flow.

Views/ListsView.xaml is contended by #161/#163/#164, so this uses the pattern
that worked on #55/#56: everything lives in PoweredTemplatePanel and its own
ViewModel, and the host contributes exactly one line —

  <local:PoweredTemplatePanel OpenListCommand="{Binding SelectListCommand}"
                              RefreshCommand="{Binding LoadListsCommand}"/>

— which is the whole diff to ListsView.xaml. No code-behind edit, no new
resource, no visibility rule: the control collapses itself when #10's
IsAiAvailable gate is closed, so a free account sees nothing at all.

## The DSL, live-verified for the first time

The list artifact was contract-transcribed only until now — #137's own doc
comment says the shape was never exercised. One /suggest call settled it:

  dsl: { name, description, fields: [ { key, type, label, required,
         displayOrder, options?, defaultValue?, visibility? } ] }
  rows: [ { <fieldKey>: value } ]   // flat maps, null is a legal cell value

Nothing in this repo had the dsl wrapper's name/description members, nor the
per-field displayOrder, options, defaultValue or visibility.condition — all of
which turned up unannounced on the very first call. That is the whole argument
for AiListDsl being a deliberately *partial* projection: it parses the five
members the column editor needs and round-trips every other member byte-for-
byte out of a retained raw element. Renaming a label must not silently drop a
conditional-visibility rule the model wrote, and /generate re-validates the DSL
server-side, so sending back exactly what came out — minus the deliberate edits
— is the only safe default.

That round-trip is verified, not assumed: a scratch harness replays the real
/suggest body through AiListDsl + AiListArtifactPayload, drops a column,
renames a label and flips a required, and asserts 20 properties of the result —
including that visibility.condition, options[] and defaultValue all survive the
edit, that displayOrder is re-numbered without gaps, and that untouched cells
keep their original JSON kind (5 stays a number, null stays null) rather than
being re-typed from their string rendering.

AiListArtifactPayload exists alongside AiListArtifact because they are opposite
directions: the existing type is the read side, so its cells are already parsed
JsonElements, whereas cells a user just typed are strings the client types per
column. Forcing those back through JsonElement would mean serializing and
re-parsing each cell individually just to satisfy the read type.

## Quota discipline

/suggest and /generate are two units, so both are pre-flighted. Suggest checks
the 300-word powered_template cap (mirrored, with a live word counter that turns
amber before the button is pressed). Confirm checks a non-empty title, at least
one column, the documented 20-field ceiling, unique keys, and the documented
^[a-z][a-z0-9_-]*$ key rule — user-added columns are slugified into that shape
locally, because learning a key was illegal from a failed /generate costs a
unit. Field keys are intentionally not editable: rows address cells by key, so
retyping one would orphan a column's data silently.

Column type pickers offer only the types this client has seen the server emit
(text, number, select) plus whatever types the artifact in hand already uses.
Inventing a DSL type would be a guess the server bills a unit to refuse.

Confirm treats /generate's {listId} as a hint, not a fact — that envelope is
still contract-transcribed — and re-fetches with GET /api/lists/{id} before
handing the host anything. If no id comes back, it refreshes the host's browser
and says plainly that a retry would duplicate the list and spend another credit,
rather than claiming either outcome.

/suggest calls made: 1 (powered_template, "a reading list with title, author,
status, and rating"). POST /api/ai/generate was NOT called — it persists to a
shared account — so the confirm path's response handling is the one part of
this that is reasoned rather than observed.

Closes #14

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conflict in ListsView.xaml: #174's PoweredTemplatePanel and #161's column form
builder both insert at the same point in the right pane. Two independent panels,
both wanted — kept both, AI first.

That order is deliberate rather than incidental: the Powered Template panel is a
compact entry point that hides itself when AI is unavailable, while the column
builder is a large editor shown only when opened. AI above the form also matches
the web, where Powered Templates is a tab over the new-list form.

Debug and Release both build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Adron
Adron merged commit 3f4ce57 into main Sep 24, 2026
1 check passed
Adron added a commit that referenced this pull request Sep 24, 2026
ListsView.xaml conflict: GitHubListHeader (#74) and the PoweredTemplatePanel +
column form builder (#174/#161) all insert at the top of the right pane. All
additive — kept all three.

Order is deliberate: GitHubListHeader goes FIRST because it identifies the
selected list (owner/repo link, private-repo tag, Refresh from GitHub), so it
belongs directly under the title rather than below a large editor panel.

Debug and Release both build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AI: Powered Templates (generate a list from a description)

1 participant