Skip to content

feat(picker): normalize aliases + Choices.js tag picker - #183

Merged
martinyde merged 13 commits into
feature/issue-177-language-model-pickerfrom
feature/issue-177-picker-choicesjs
Jul 7, 2026
Merged

feat(picker): normalize aliases + Choices.js tag picker#183
martinyde merged 13 commits into
feature/issue-177-language-model-pickerfrom
feature/issue-177-picker-choicesjs

Conversation

@martinydeAI

Copy link
Copy Markdown
Collaborator

Links to issues

Refs #177.

Description

Layered onto PR #178. Two orthogonal picks:

  • Server-side normalise (Option 1). AssistantCreator now
    runs the submitted languageModel through
    ModelMap::normalise() before construction, so aliases (e.g.
    openai/gpt-4o, gpt-4o-2024-08-06) fold to the canonical id
    (gpt-4o) at persist time. Unknown / free-typed values pass
    through untouched. Keeps the catalog's language-model facet
    deduplicated no matter which spelling a curator submitted.
  • Choices.js on top (Option 2). A Stimulus controller
    enhances the free-text languageModel input with Choices.js
    in text-input mode (maxItemCount: 1, addItems: true). The
    dropdown is seeded with the union of ModelMap::choices()
    and AssistantRepository::persistedLanguageModels()
    (case-insensitive dedup, canonical spelling wins ties).
    Aliases from model_map.yaml come through on each choice's
    customProperties.aliases so Fuse.js filters as the curator
    types any known spelling — typing openai/gpt narrows the
    dropdown to gpt-4o. The pill preserves whatever the curator
    typed; normalisation only happens on the server.

Datalist fallback for no-JS clients is unchanged.

Primary files:

  • src/Assistant/AssistantCreator.php — normalise on persist.
  • src/Assistant/Model/ModelMap.php — new aliasesFor().
  • src/Repository/AssistantRepository.php — new
    persistedLanguageModels().
  • src/Form/AssistantMetadataStepType.php — widen finishView()
    to expose union + aliases.
  • assets/controllers/language_model_picker_controller.js
    Choices.js text-input mode.
  • templates/assistant/_new_step_metadata.html.twig — wire the
    controller onto the field.

Screenshot of the result

To be added — screenshots of the picker (canonical + persisted
dropdown, alias-search filtering, free-tag entry) will follow
after manual smoke on the review env.

Checklist

  • My code is covered by test cases.
  • My code passes our test (all our tests).
  • My code passes our static analysis suite.
  • My code passes our continuous integration process.

Additional comments or questions

Client-side normalise-on-commit (Option 3) is deliberately not
in this PR — deferred pending manual test of the current
"typed value stays visible in the pill" behaviour.


Details - AI specificities

Normalisation shape.
AssistantCreator::create() now folds $languageModel via
$this->modelMap->normalise($languageModel) ?? $languageModel
right before instantiating the Assistant. Passing through the
null fallback preserves free-typed values verbatim (the
fallback: passthrough policy ModelMap documents). The
AssistantDraftPrefiller already normalises on the import
path, so the two persistence entry points now agree.

Union merge shape.
AssistantMetadataStepType::finishView() builds model_choices
as label => id:

  1. Seed with ModelMap::choices() — canonical shortlist, in
    map order.
  2. Fold each persisted value case-insensitively into the seen
    set; append it to the choices when it's not already covered.
    Canonical spelling wins on ties.

Aliases are exposed as a parallel model_aliases map
(id => list<string>), the source of truth ModelMap
returns via the new aliasesFor() accessor.

Client controller shape.
language_model_picker_controller.js is a thin Choices.js
wrapper:

  • Text-input mode on the existing <input type="text">, so the
    underlying form element and its submitted name don't change.
  • maxItemCount: 1 + addItems: true — single pill,
    free-tags allowed.
  • searchFields: ['label', 'value', 'customProperties.aliases']
    — Fuse.js indexes the canonical id, its label, and the
    space-joined alias tokens for each row.
  • setChoices() is called post-init with the union list Twig
    encoded into a data-language-model-picker-choices-value
    attribute.

Pill preserves typed value.
Per the user's preference (openai/gpt-4o is what curators are
used to seeing), the pill shows whatever the curator typed.
Server-side normalise is what folds the value to gpt-4o for
storage; the two-way discrepancy is intentional.

Test additions.

  • AssistantCreatorTest — one test proving
    openai/gpt-4ogpt-4o on persist, one proving unknown
    values (my-local-llm) survive verbatim.
  • AssistantRepositoryTest — a new
    testPersistedLanguageModelsListsDistinctStoredValues test.
  • ModelMapTest — new testAliasesForReturnsDeclaredAliasesOnly
    test.
  • AssistantMetadataStepTypeTest — expanded to cover the
    canonical+aliases exposition, the persisted-extras append,
    and the case-insensitive dedup with canonical winning.

Coverage. 565 tests, 1674 assertions, 100.00 % on the
coverage gate.

Related.

Server-side, AssistantCreator now folds submitted language-model
values (including aliases like openai/gpt-4o) through ModelMap to
their canonical id before persisting, keeping the catalogue's
facet deduplicated regardless of the spelling a curator typed.

Client-side, a Stimulus controller enhances the free-text
languageModel input with Choices.js in text-input mode. The
dropdown offers the union of ModelMap::choices() and every
languageModel value already in the catalogue (case-insensitive
dedup, canonical spelling wins on ties), and aliases from
model_map.yaml are exposed on each choice's customProperties so
Fuse.js filters the dropdown as the curator types any known
spelling. Free-typed values commit as-is via addItems and the
new addItemText callback, and the pill preserves whatever the
curator typed.

The datalist fallback survives for no-JS clients. New
AssistantRepository::persistedLanguageModels() feeds the union
list, and new ModelMap::aliasesFor() exposes the per-canonical
alias tokens the picker searches on.
The previous commit ran Choices.js in text-input mode on the
languageModel `<input>` and seeded choices via setChoices() —
Choices.js rejects that combination at runtime with
"setChoices cannot be used with input based choices", breaking
`/assistant/new` step 2 for every visitor.

Render the field as a `<select>` in the template (manual render
so the field stays TextType server-side and free-typed values
still round-trip verbatim). The `<option>` list is the same
union AssistantMetadataStepType::finishView() already exposes
via `model_choices` — canonical shortlist plus every persisted
value, case-insensitive dedup, canonical spelling wins ties.

The controller now wraps the `<select>` in Choices.js's
select-one search combobox with `searchChoices: false` and
owns the `search` event: on every keystroke it emits a
filtered list matching value / label / any alias from
model_map.yaml (typing `openai/gpt` narrows to `gpt-4o`), and
appends the raw typed value as a pickable row when it isn't
already a known option. On `choice`, freetagged commits get
promoted to the known set so subsequent keystrokes don't
duplicate them. Pill still shows whatever the curator typed.

Drops the now-unused MODEL_DATALIST_ID const, the `list`
attribute on the field, and the datalist markup in the
template. No-JS clients keep a plain `<select>` populated with
the same option list.
…ect loop

With the controller bound to the `<select>` itself, Choices.js
re-parenting the select into its own DOM wrapper on init took
the controller node out of the live tree — Stimulus fired
disconnect, then reconnect once Choices.js re-inserted it,
running `new Choices()` again, and so on in a loop.

Move `data-controller="language-model-picker"` and its values
onto a stable wrapper `<div>` around the `<select>`, and look
the select up with `querySelector` inside `connect()`. The
wrapper never leaves the tree, so the controller initialises
exactly once.
… legend

Three visible glitches on `/assistant/new` step 2:

- `Sprogmodel` rendered twice. The template renders the
  language-model widget manually as a `<select>` (so Choices.js
  can enhance it) but never marked the underlying form child as
  rendered — `form_end()` then emitted the auto-widget with its
  own label a second time. Mark the child via `setRendered` after
  the manual render.
- A dangling "Metadata" legend above the fields. Same root cause:
  `form_end()` was seeing the compound step form with one
  unrendered leaf and emitting the whole thing (label + legend +
  unrendered leaf). Fixed by the setRendered above — with every
  leaf rendered, the compound emits nothing.
- Redundant "Annuller" link + inline previous/next buttons.
  Dropped the link and moved the navigator into a right-aligned
  button row rendered explicitly, so we can style each button
  (previous → subdued outline, next / finish → primary). Each
  button self-hides via NavigatorFlowType's `include_if`
  callback — `previous is defined` guards keep the render safe
  on the first / last steps.

Previous-button label changed to "Indsæt anden JSON" — clearer
than "← Forrige trin" now that the button is on the metadata
step and its only effect is to go back to the JSON entry step.

Dropped the now-unused `assistant.new.action.cancel` translation
key.
…n 1-2

Step 3 (receipt) now surfaces three actions inline instead of
two: "Gå til assistent" (button-styled anchor to the new
entity), "Del en til" (the flow's Finish submit, which resets
session state and lands back on step 1), and "Tilbage til
kataloget" (text link to the catalog).

The three sit in a left-aligned row inside the receipt partial
itself so the anchor links can live alongside the Finish submit
without needing a wrapper form. The parent shell (`new.html.twig`)
skips its own navigator row on the receipt step and passes
`flow` into the include so the Finish widget can be rendered
inline. `setRendered` on the whole navigator keeps `form_end`
from re-emitting the still-defined-but-unused `previous` button
below the actions row.

Steps 1 and 2 keep their right-aligned Previous / Next pair
(previous only appears on step 2 per `include_if`).
The Finish button on step 3 was wired to
`assistant.new.step_receipt.share_another`, but the actual
translation lives under `assistant.new.action.share_another`
— the receipt page missed the translation and rendered the
raw key. Point the label at the correct path.
`AssistantReceiptStepType` is a render-only step with no fields
of its own. `FormView::isRendered()` on a childless compound
just returns its own `$rendered` flag (nothing to walk into),
so `form_end` at the bottom of `new.html.twig` would auto-emit
the compound with its default humanized legend — "Receipt" —
below the actions row.

Mark the compound rendered explicitly in the receipt partial to
suppress it, alongside the existing `flow.navigator.setRendered`
that handles the leftover previous button.
Symfony's Validator component translates constraint messages via
the `validators` translation domain by default. The wizard's
five constraint messages (`step_json.required` +
`step_json.invalid_config`, plus `title_required`,
`description_required`, `language_model_required` on
`step_metadata`) lived in `messages.da.yaml` — so
`form_errors()` looked them up in the wrong catalogue and
rendered the raw keys.

Move all five entries to `translations/validators.da.yaml`
under the same nested paths. `step_json.unrecognised_format`
stays in `messages.da.yaml` — it's rendered via
`$translator->trans(...)` from the controller (default domain
= `messages`), not via a violation.
…ion family

The step-1 config validator now emits one violation per deduped
detail line instead of a single violation with a `; `-joined
`{{ errors }}` placeholder. Symfony's default `form_errors`
renders the sequence as a real `<ul>`, so the user sees a
bulleted list of reasons rather than a semicolon-separated
paragraph.

The first violation is the localised intro line
(`assistant.new.step_json.invalid_config` →
"Filen er ikke en gyldig assistent-konfiguration:") from the
existing `validators` catalogue; each following violation is
one detail line.

Each detail line is passed through a new
`assistant_validation` translation domain
(`translations/assistant_validation.da.yaml`) so the finite
JSON-decoder message family (PHP's `\JsonException`) can be
localised without cluttering the general validator catalogue.
Opis JsonSchema errors carry interpolated `{property}`/`{path}`
context — they fall through the catalogue as English rather
than requiring a per-keyword mapping that would drift with
library bumps.

Test updates: `testSchemaInvalidRaisesViolation` and
`testMalformedInputRaisesViolation` now chain `buildNextViolation`
per detail line to match the new shape. `createValidator` stubs
a passthrough `TranslatorInterface` so the assertions keep
matching the raw adapter error strings.
Tailwind's preflight strips `list-style` and left padding from every
`<ul>`, so the config-validator's per-error list rendered as a
column of naked text lines inside the alert box.

Register an inline `_self` form theme on the wizard form that
overrides `form_errors` with `list-disc space-y-1 pl-5`. Scoped
to `new.html.twig` — no site-wide styling change. Also applies to
the language-model field's inline `form_errors(language_model)`
on step 2, which is the same styling.
…leted list

Two glitches on step 1 when the config validator fires:

- Errors rendered twice — once via `form_errors(flow)` in the
  wizard's alert box and again next to the textarea via
  `form_row(flow.json.sourceConfig)`. The violations attach to
  the `sourceConfig` field by default, so both spots pulled from
  the same list and the user saw duplicates.
- The bulleted list styling from the earlier `_self` form-theme
  override didn't take effect. Tailwind's preflight strips
  `list-style` on every `<ul>`, and the override was competing
  with existing form theming rather than owning the render.

Fixes:

- `error_bubbling: true` on the `sourceConfig` field. The parent
  step has `inherit_data: true`, whose default `error_bubbling`
  is true — so the errors climb the compound and land on the
  flow root. `form_row(...)` on the field no longer sees any
  attached errors and stops rendering the duplicate row.
- Drop the `_self` form-theme override; render the alert box
  contents directly in `new.html.twig`. First error (the intro
  "Filen er ikke en gyldig assistent-konfiguration:") is a
  `<p>` heading. Remaining errors become a `<ul>` with
  `list-disc space-y-1 pl-5` classes, which bypass Tailwind's
  preflight because the utilities set `list-style-type`
  explicitly.

No test changes — the validator's violation shape is
unaffected; only the field's `error_bubbling` option and the
wizard shell's render change.
Two adjustments to the wizard's error box:

- Drop the "first error is a summary" treatment. Any error in
  the list could arrive on its own (there is no guarantee the
  config-validator's intro line is always first once other
  validators contribute), so treating the first entry as a
  paragraph heading and the rest as bullets is inconsistent.
  Render every error as a plain `<li>` bullet.
- Use the shared `<twig:Alert type="error">` component instead
  of hand-rolling the alert `<div>`. Same visual today, but any
  future colour/icon treatment on the component reaches this
  spot for free.

Bullets are re-styled with `list-disc pl-5` and
`marker:text-text` so the disc glyphs actually show through
Tailwind's preflight and match the surrounding muted text.
Tailwind's preflight sets `list-style: none` on every `<ul>` in
the project's base CSS, and the `list-disc` utility couldn't
overcome it in this spot. Bullets never showed.

Drop `list-disc` entirely; render the bullet as a `::before`
pseudo-element on each `<li>` with `content-['•']` positioned
absolutely at `left-0` inside a `pl-4` inset. Coloured via
`before:text-text` so the glyph matches the surrounding muted
body copy. Unaffected by any `list-style` reset upstream.
@martinyde
martinyde merged commit 8790bfc into feature/issue-177-language-model-picker Jul 7, 2026
8 checks passed
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.

2 participants