A question that offers real choices, and says how many you may take

Three things about `ask_user`, all of them about the card being answerable
rather than about the tool being callable.

Options are required now, and they are objects: a label, and a line of
description where the label alone does not say what choosing it would mean.
"Rewrite it" and "Patch it" are two words that do not tell you which one loses
your uncommitted work. They stack one per line, because a row of chips has
nowhere to put the second line and no room to read the first.

The model says whether they are exclusive. Only it knows whether its options are
alternatives or a set, and the card has to show which -- a radio group offered
where checkboxes were meant loses every answer but one. Exclusive is the
default, being the cheaper mistake. A `multiple` question posts the same field
name once per ticked box, so the endpoint gathers choices into a list; the
`setdefault` it did before kept the first and dropped the rest, which is an
answer that says something the reader did not.

And "Something else" is added here, on every question, with the box behind it
revealed by `:has()` and no JavaScript at all. The model is told never to write
an "other" option of its own, because its version would be a choice with no box
behind it -- a word submitted that means nothing. It carries a sentinel rather
than an answer, and the endpoint swaps in what was typed beside it, or drops it
when the box was left empty rather than telling the model the answer is
"__other__".

Typing no longer beats picking. That rule belonged to a box that was always
visible next to the options; this one only exists once its own option is chosen,
so picking is the answer and the box is one of the things you can pick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-04 19:34:47 +02:00
parent ffe4966aac
commit c0b72df6af
7 changed files with 491 additions and 39 deletions
+32 -5
View File
@@ -867,7 +867,10 @@ def _ask_html(chat_id: str, pending) -> str:
if pending is None:
return ""
return templates.get_template("chat/_interaction.html").render(
{"ask": pending, "chat_id": chat_id}
# The sentinel the "Something else" row submits, passed in rather than
# written into the template, so the value the card sends and the value
# this module looks for cannot drift apart.
{"ask": pending, "chat_id": chat_id, "other_value": interaction.OTHER}
)
@@ -1412,16 +1415,40 @@ async def answer_interaction(
form = await request.form()
verdict = str(form.get("verdict") or "").strip()
answers: dict[str, str] = {}
# Gathered in two passes because one field can now arrive several times: a
# question the model marked `multiple` is checkboxes, and every ticked one
# posts under the same name. A `setdefault` would keep the first and lose
# the rest, which is an answer that says something the reader did not.
chosen: dict[str, list[str]] = {}
typed: dict[str, str] = {}
for field, value in form.multi_items():
kind, _, key = str(field).partition(".")
if not key or kind not in ("choice", "text"):
continue
written = str(value).strip()
if kind == "text" and written:
if kind == "text":
typed[key] = written
elif written:
chosen.setdefault(key, []).append(written)
answers: dict[str, str] = {}
for key, picks in chosen.items():
# The "Something else" row carries a sentinel, not an answer: what it
# means is whatever was typed beside it. Dropped entirely when the box
# was left empty, so ticking it and writing nothing is the same as not
# ticking it -- rather than the model being told the answer is
# "__other__", which is the shape of thing it would try to act on.
parts = [pick for pick in picks if pick != interaction.OTHER]
if interaction.OTHER in picks and typed.get(key):
parts.append(typed[key])
if parts:
answers[key] = ", ".join(parts)
# A box with no choice beside it: the approval card's corrected command,
# which is the one place `text.` still stands on its own.
for key, written in typed.items():
if written and key not in answers and key not in chosen:
answers[key] = written
elif kind == "choice" and written:
answers.setdefault(key, written)
# Read and recorded *before* resolving: `interaction.wait_for` clears
# `generation.pending` in its `finally`, so a moment later there is nothing