Skip to content

Let Magic Compose answer with questions, and draft from the answers - #573

Open
richshaw wants to merge 9 commits into
claude/pensive-thompson-quytutfrom
claude/pensive-thompson-quytut-2-server
Open

richshaw wants to merge 9 commits into
claude/pensive-thompson-quytutfrom
claude/pensive-thompson-quytut-2-server

Conversation

@richshaw

@richshaw richshaw commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Part of #570, the second of five stacked PRs. It's based on #572 (friendly limit messages). It's dormant on its own: the prompt doesn't ask questions until #577, so nothing an organizer sees changes.

What it adds

Magic Compose can now answer a description with a short list of what's missing instead of a draft. The organizer's answers can come back in a second request. That second request must end in a draft or a refusal, never more questions.

The model's questions reply

{ "questions": { "topics": ["what", "when", "how_many"], "whatOptions": ["Bake something to sell", "Staff the table"] } }
  • QuestionsSchema in prompt.ts is .strict() at both levels.
    • topics: one to three of what, when and how_many, each once, always with what.
    • whatOptions: two to four distinct options of up to 40 characters, tidied the way the answers are.
  • It's the middle arm of MagicComposeDraftSchema.
    • A refusal still wins, as before.
    • The draft arm refuses a questions key, so a reply that mixes questions with half a draft fails.
    • Null top-level keys are dropped first, as models in json_object mode send them, so a draft with "questions": null is still a draft.
    • FullDraftSchema, which the MCP create_signup tool uses, is unchanged.
  • A first request the model answers with questions gets 200 { data: { questions } }, with no signup and no links. It's logged at info with the topics and the number of options, never the options.

The second request

POST /api/signups/magic-compose takes an optional followUp, one key per topic that was asked:

{
  what: { picked: string[]; added: string[] };   // ≤ 4 picked, ≤ 12 in total, 1–80 chars each, no repeats
  when?: { kind: 'one_day', date, from?, to? } | { kind: 'several', text } | { kind: 'not_decided' };
  howMany?: (number | null)[];                    // one per item, picked then added; whole 1–9999, null = no limit
  placeholders?: true;                            // Draft with placeholders: every answer blank
}
  • Checks. FollowUpSchema (src/lib/magic-compose/follow-up.ts) requires:

    • a real date;
    • times that exist, with the end after the start;
    • whole-number counts lined up with the items;
    • no repeated items, ignoring case;
    • no answers alongside placeholders.

    Text goes through tidyText (shared with the page). A bad followUp is invalid_input on field: 'followUp', before the quota is charged.

  • The message. buildFollowUpMessages puts the description and the answers under fixed labels in one user message, with the organizer's own words JSON-quoted:

    Bake sale for the PTA
    
    Answers to your questions:
    - What people sign up for: "Bake something to sell", "Staff the table"
    - When: 2026-11-14, 09:00 to 13:00
    - How many people for each: "Bake something to sell": no limit, "Staff the table": 2
    
    Use these exact values. Draft the signup now. Don't ask questions.
    

    A skipped topic reads "not given, use a TBD: placeholder". A topic that wasn't asked has no line.

  • If the model asks again. The route retries once with a firmer last line and no extra quota.

    • If the retry asks too, it answers invalid_input with details.reason: 'asked_again' and "Couldn't draft from these answers. Add a little more, or draft with placeholders." After Draft with placeholders, the message is "Couldn't draft this. Try again, or fill it in by hand."
    • A reply counts as asking again whatever its shape (asksQuestions in prompt.ts). After the answers, a model most likely asks about less than the first time.
  • Time. The whole request gets one LLM_TIMEOUT_MS. generateDraft takes an optional timeoutMs, so the retry gets what's left, and running out is still a timeout. With under 15 seconds left (or half the budget, if that's less), or the organizer gone, it skips the retry. That keeps a follow-up inside the browser's 210-second watchdog.

  • Record link copies, failed AI drafts and refused publishes #525's failure telemetry. Every failed path still records magic_compose.failed, and asked_again joins its codes (aborted when the organizer had left). A first call answered with questions isn't a failure and records none. docs/telemetry.md lists the new code.

  • Privacy. The privacy page now says any answers go to the AI provider too.

Where this differs from the issue's wording

Tests

  • prompt.test.ts:
    • the issue's schema cases (accepts a questions reply; rejects one without what, a repeated topic, one or five options, a reply mixing questions and draft keys, and a reply with none of the three);
    • null keys;
    • tidied options;
    • asksQuestions.
  • follow-up.test.ts: every check above, and the message word for word.
  • llm-client.test.ts: a shorter timeoutMs times out as timeout and can't lengthen LLM_TIMEOUT_MS.
  • route.questions.db.test.ts, with the real route, quota and database and a stubbed model:
    • a name-only prompt gets questions and creates no signup;
    • a follow-up drafts;
    • questions twice give asked_again after exactly one retry and one quota charge;
    • a partial or mixed questions reply is retried;
    • a draft with "questions": null drafts in one call;
    • a retry that times out;
    • a refusal after answering;
    • an aborted request;
    • the retry floor, with the clock moved on;
    • a bad date, times out of order and a fractional count are rejected before the quota or the model.
  • route.failed.test.ts (Record link copies, failed AI drafts and refused publishes #525's) gains asked_again, and "no failure for a first-call questions reply".
  • pnpm lint && pnpm typecheck && pnpm knip && pnpm test (1423) and pnpm test:db (555) pass.

Tophat

This branch on its own, against a local fake OpenAI-compatible endpoint, through the API:

  • "Bake sale" gets the questions;
  • answers create a signup;
  • a model that asks twice gets one firm retry, then asked_again;
  • a bad date, or placeholders with answers, is refused before any model call;
  • the server log holds topics and lengths only, no answer text.

On this branch alone, the existing page shows "AI drafting failed (HTTP 200)" if a model ever answers with questions. That's expected: the screen comes in #576, and this branch's prompt never asks.

Reviews

  • The first review, fixed in 06981ec: the route only noticed a re-ask that passed the strict schema.
  • A second review (/code-review), fixed in a83a270:
    • a draft with "questions": null failed (and was retried as a re-ask);
    • options and answers were tidied differently.
  • Left as is:
    • overnight times (the issue asks for times in order);
    • both calls counting against the hourly limit (the issue's decision; the answers stay on screen if the limit is hit);
    • the existing log of malformed model output on a schema failure (it predates this PR, and it's how operators debug).

main (with #525 and #526) is merged in.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks

… message

The contract for the questions round, with nothing asking yet: the prompt
is unchanged, so the model has no reason to send questions.

- QuestionsSchema in prompt.ts: { questions: { topics, whatOptions } },
  strict at both levels. topics are one to three of what, when and
  how_many, each once, always with what; whatOptions are two to four
  distinct options of up to 40 characters. MagicComposeDraftSchema takes
  it as a third arm; a refusal still wins, and the draft arm refuses a
  questions key so a reply mixing the two fails. FullDraftSchema, which
  the MCP create_signup tool uses, is unchanged.
- src/lib/magic-compose/questions.ts: the shared types (FollowUp,
  WhenAnswer, MagicComposeQuestions) and the topic list, without zod, so
  the page can import them; limits.ts holds the numbers.
- src/lib/magic-compose/follow-up.ts: FollowUpSchema checks the
  organizer's answers (a real date, times in order, whole counts lined up
  with the items, no repeated items, placeholders with no answers), and
  buildFollowUpMessages puts the description and the answers under fixed
  labels in one user message, the organizer's words JSON-quoted.
- The route answers a questions reply with 200 { questions } and no
  signup, logged at info with the topics and how many options.

Part of #570.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks
A follow-up to Magic Compose's questions may need a second call to the
model, and the organizer should wait for both together no longer than
LLM_TIMEOUT_MS. generateDraft takes an optional timeoutMs for that: the
second call gets what the first left. It can only shorten the limit, never
lengthen it.

The limit has to live in the client rather than in a shorter signal from
the caller: the client reports a caller's signal as "aborted" (the organizer
left), which is the wrong message for running out of time. Its timeout
message now names the limit that ran out.

Part of #570.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks
POST /api/signups/magic-compose now takes the answers to a questions reply
as followUp, beside the description. They are checked before the hour's
quota is charged: a date that isn't a real day, times out of order or a
count that isn't a whole number comes back as invalid_input against
followUp, and costs nothing.

The model then gets the description and the answers in one message, and
the reply must be a draft or a refusal. A model that asks again gets one
firmer retry, without another charge. If it still asks, the organizer
reads "Couldn't draft from these answers. Add a little more, or draft with
placeholders." (after Draft with placeholders, "Couldn't draft this. Try
again, or fill it in by hand."), with details.reason asked_again, so the
page can keep their answers on screen.

Both calls share one LLM_TIMEOUT_MS budget, so a retry never doubles the
wait. With under 15 seconds left, or the organizer already gone, the
retry is skipped and the answer is asked_again. The log lines give the
description's length, never the answers.

Nothing asks yet: the prompt is unchanged. The new route tests stub the
model and run against the real quota and database.

Part of #570.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks
The route only noticed the model asking again when the reply passed
QuestionsSchema. After the answers, a model that asks again most likely
asks about less than the first time (only "when", with no options),
which the schema rejects, so the organizer got "AI draft did not match
expected shape" with no retry. A reply with a questions key now counts as
asking again, whatever its shape; a refusal beside it is still a refusal.

The retry's 15-second floor is now at most half of LLM_TIMEOUT_MS, so an
instance with a short timeout still gets its one retry. The privacy page
says the answers go to the AI provider too, since the route sends them
from this change on.

Tests: a partial and a mixed questions reply are retried; a retry that
asks again in another shape ends in asked_again; a retry that times out
reads as a timeout; the floor, with the clock moved on during the first
call.

Part of #570.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks
Second review of this PR:

- Models in json_object mode often send null for keys they don't use;
  the draft schema already accepts that for choices and groupBy. A
  complete draft with "questions": null failed the draft arm (which
  refuses a questions key) and reached the organizer as "AI draft did
  not match expected shape", a draft that worked before this change.
  MagicComposeDraftSchema now drops null top-level keys before it reads
  the reply, so that draft is a draft and questions with
  "refusalReason": null are questions.
- asksQuestions moves into prompt.ts beside the schema and reads the
  reply the same way, so a draft with "questions": null is no longer
  retried as if the model asked again.
- tidyText, in the shared questions.ts, is the one whitespace tidy-up:
  the answers' schema uses it, and so do the model's options, so an
  option with a doubled space and the organizer's own item typed with
  one can't pass the page's duplicate check and fail the server's.

Part of #570.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks
Brings in #525 and #526. The route keeps #525's magic_compose.failed
row on every failed path, and adds asked_again to its codes: the model
asked again after the answers, even after the retry (aborted when the
organizer had left). A first call the model answers with questions is an
answer, not a failure, and records none. #525's route test stubs the env,
so its stub now carries the AI's time budget the route reads; it gains a
case for each of those two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks
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