Ask several questions on one card

One `ask_user` call can now carry several questions, and they come back in a
single submit. Asking one at a time cost a round trip and an interruption
each, and by the third you had forgotten the first.

Each question becomes an item with its own key; several items share a call
index, because they belong to one call and one tool turn has to answer them
all. Each answer is quoted beside the question it belongs to -- with four on
a card, a bare list would leave the model matching them up by position and
sometimes getting it wrong.

Options are radios rather than submit buttons, so picking one does not send
the form while two other questions are still blank. What you type beats what
you picked: someone who writes in the box after clicking an option meant the
writing.

`_questions_in` also reads the shapes a small model actually sends -- a bare
`question` string, a list of plain strings, one object where a list belonged.
Getting that wrong costs a whole round trip and shows a card saying nothing.

Two test fixes, both mine. `test_posting_a_message_stores_both_turns` raced
the background generation it started: against a connection that refuses
instantly the reply sometimes won, writing the error and marking the row
complete before the assertions could read it. And the generation registry is
module-global, so a test that started a reply left an entry -- and a Task
belonging to a closed event loop -- for the rest of the session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-01 22:01:46 +02:00
parent 8c3fe97939
commit 671e49cae8
10 changed files with 673 additions and 138 deletions
+25 -4
View File
@@ -675,14 +675,19 @@ async def stop_message(db: Db, user: RequiredUser, chat_id: str, message_id: str
@router.post("/{chat_id}/interaction/{interaction_id}")
async def answer_interaction(
request: Request,
db: Db,
user: RequiredUser,
chat_id: str,
interaction_id: str,
choice: str = Form(""),
text: str = Form(""),
) -> Response:
"""Answer a question, or allow something, that a reply is waiting on.
"""Answer the questions, or allow the action, a reply is waiting on.
The whole card comes back at once, which is why the raw form is read rather
than declared parameters: one `ask_user` call may have put four questions,
and each carries a chosen option and a box to write something else. Per
question, what was written wins over what was picked -- somebody who typed
in the box after clicking an option meant the typing.
`_owned_chat` is the authorisation and it is not decoration: without it any
signed-in account that guessed an id would be answering -- and later,
@@ -694,8 +699,24 @@ async def answer_interaction(
being told plainly.
"""
chat = _owned_chat(db, chat_id, user.id)
form = await request.form()
answers: 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:
answers[key] = written
elif kind == "choice" and written:
answers.setdefault(key, written)
answered = generation_service.answer(
chat.id, interaction_id, choice=choice.strip(), text=text.strip()
chat.id,
interaction_id,
verdict=str(form.get("verdict") or "").strip(),
answers=answers,
)
response = Response(status_code=status.HTTP_204_NO_CONTENT)