diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index ed4e691..283ad94 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -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 diff --git a/src/lembas/services/generation.py b/src/lembas/services/generation.py index a34e282..91a8851 100644 --- a/src/lembas/services/generation.py +++ b/src/lembas/services/generation.py @@ -1377,7 +1377,6 @@ def _ask_items(context, calls: list[dict], arguments: list[dict]) -> list[intera args = arguments[index] for asked in _questions_in(args): - options = [str(o).strip() for o in (asked.get("options") or []) if str(o).strip()] items.append( interaction.Item( index=index, @@ -1385,12 +1384,50 @@ def _ask_items(context, calls: list[dict], arguments: list[dict]) -> list[intera kind=interaction.KIND_QUESTION, tool_name=call["name"], title=str(asked.get("question") or "").strip() or "A question for you", - options=tuple(options[: interaction.MAX_OPTIONS]), + options=_options_in(asked), + multiple=bool(asked.get("multiple")), ) ) return items +def _options_in(asked: dict) -> tuple[interaction.Option, ...]: + """The choices offered for one question, however they were spelled. + + The schema asks for objects with a `label` and an optional `description`, + and a capable model sends that. A small one sends a list of bare strings -- + which is what the schema asked for until recently and is what most examples + of this pattern look like -- so that is read as a label with no description + rather than refused. Anything else in the list is dropped rather than + stringified, because `{'a': 1}` rendered as a choice is worse than one + choice fewer. + + An option meaning "something else" is **not** added here. It belongs to the + template, which adds it to every question and owns the box behind it; adding + it to the data would make it indistinguishable from one the model wrote. + """ + out: list[interaction.Option] = [] + for raw in asked.get("options") or []: + if isinstance(raw, str): + label, description = raw.strip(), "" + elif isinstance(raw, dict): + label = str(raw.get("label") or raw.get("name") or raw.get("value") or "").strip() + description = str(raw.get("description") or "").strip() + else: + continue + if not label: + continue + out.append( + interaction.Option( + label=label[: interaction.MAX_OPTION_CHARS], + description=description[: interaction.MAX_OPTION_CHARS], + ) + ) + if len(out) >= interaction.MAX_OPTIONS: + break + return tuple(out) + + def _questions_in(args: dict) -> list[dict]: """The questions in one `ask_user` call, however it was spelled. diff --git a/src/lembas/services/interaction.py b/src/lembas/services/interaction.py index 7818e00..d47cbeb 100644 --- a/src/lembas/services/interaction.py +++ b/src/lembas/services/interaction.py @@ -54,6 +54,32 @@ MAX_OPTIONS = 6 # what it learned. MAX_QUESTIONS = 8 +# The value the "Something else" row submits. A sentinel rather than a real +# option, because it is the one choice on the card the model did not write: it +# is added by this code, always, to every question. That is the whole reason the +# model is told never to offer an "Other" of its own -- two of them is one that +# does nothing, and the model's version would have no box behind it. +OTHER = "__other__" + +# How many characters of an option's description are kept. It is a sentence +# explaining a choice, not a paragraph, and it is model output landing in a +# card somebody is meant to read at a glance. +MAX_OPTION_CHARS = 240 + + +@dataclass(frozen=True) +class Option: + """One answer offered for a question. + + A `label` alone reads as a button; the optional `description` is what makes + a real choice possible -- "Rewrite it" and "Patch it" say nothing about + which loses your uncommitted work. Both are model output and are escaped + where they are shown. + """ + + label: str + description: str = "" + @dataclass(frozen=True) class Item: @@ -83,7 +109,18 @@ class Item: # explanation somebody reads as the application's own would be a card # vouching for it. purpose: str = "" - options: tuple[str, ...] = () + options: tuple[Option, ...] = () + # Whether more than one option may be chosen. The model says which, because + # only the model knows whether its options are alternatives ("rewrite or + # patch") or a set ("which of these to include"). Exclusive is the default: + # a radio group offered where checkboxes were meant costs one clarifying + # round, while checkboxes offered for alternatives invite an answer that + # contradicts itself. + multiple: bool = False + # Whether "Something else" is offered, with the box behind it. True for a + # question -- the options are the model's guess at the answers and it can be + # wrong -- and false for an approval, where the choice is Allow or Don't and + # a third way out would mean nothing. allow_free_text: bool = True # Whether `detail` can be corrected before this is allowed. Only where the # detail *is* one argument and can be put back where it came from -- a tool diff --git a/src/lembas/services/tools.py b/src/lembas/services/tools.py index 97aee48..4be73d5 100644 --- a/src/lembas/services/tools.py +++ b/src/lembas/services/tools.py @@ -1005,9 +1005,20 @@ REGISTRY: dict[str, ToolDef] = { "for their answers before going on. Use it when you genuinely need " "a decision only they can make — which of several approaches to " "take, a detail you cannot infer, permission for something " - "consequential. Offer options when there is a small set of " - "sensible answers; they can always write their own instead. " - "\n\n" + "consequential.\n\n" + "**Always give options.** A question with no options is a blank box, " + "and a blank box asks the person to do the thinking you were meant " + "to do: offer the two to six answers you actually think are " + "plausible, in the order you would recommend them. Do NOT add an " + "option meaning “other”, “something else”, “none of these” or " + "“let me type it” — one is added for you, on every question, with a " + "box behind it. Yours would have no box and would do nothing.\n\n" + "Say whether the options are exclusive. `multiple: false` (the " + "default) is for alternatives, where picking one rules out the " + "rest; `multiple: true` is for a set, where any number may be " + "chosen. Give an option a `description` wherever the label alone " + "does not say what choosing it would mean — that is what makes a " + "real decision possible rather than a guess between two words.\n\n" "Ask everything you need in ONE call: they answer the whole card at " "once and it costs them a single interruption, where asking twice " "in a row costs two. Do not use it for anything you can work out " @@ -1031,14 +1042,43 @@ REGISTRY: dict[str, ToolDef] = { }, "options": { "type": "array", - "items": _STRING, "description": ( - "Up to six answers to offer for this " - "question. Optional." + "The answers to offer, two to six of them. " + "Required. Never include an “other” or " + "“something else” option — one is always " + "added for you." + ), + "items": { + "type": "object", + "properties": { + "label": { + **_STRING, + "description": ( + "The choice itself, in a few words." + ), + }, + "description": { + **_STRING, + "description": ( + "Optional: one line on what " + "choosing this would mean, where " + "the label alone does not say." + ), + }, + }, + "required": ["label"], + }, + }, + "multiple": { + "type": "boolean", + "description": ( + "Whether more than one option may be chosen. " + "False (the default) for alternatives, true " + "for a set." ), }, }, - "required": ["question"], + "required": ["question", "options"], }, }, }, diff --git a/src/lembas/web/static/css/chat.css b/src/lembas/web/static/css/chat.css index 0a7db63..1d4606b 100644 --- a/src/lembas/web/static/css/chat.css +++ b/src/lembas/web/static/css/chat.css @@ -494,9 +494,68 @@ border-top: 1px solid var(--border); } .interaction__title { margin: 0; padding: 0; color: var(--ink); font-weight: 500; } -.interaction__options { display: flex; flex-wrap: wrap; gap: var(--sp-2); } +/* Stacked, one per line. A row of chips was fine while an option was two words + and nothing else; an option now carries a description as well, and a row has + nowhere to put the second and no room to read the first. */ +.interaction__options { display: flex; flex-direction: column; gap: var(--sp-2); } .interaction__note { color: var(--ink-faint); font-size: var(--text-xs); } +/* One option: the control, then a label and an optional line under it. The + whole row is the target -- it is a `