Skip to content

Article Genie LLM Integration ​

How the Article Genie talks to a language model: the seam, the turn protocol, the prompt contract, and what happens when the daily quota runs out. The decision behind this feature, and the alternatives rejected, lives in ADR 0006; this document describes the mechanism.

Scope: the Worker does exactly one thing ​

Everything except the model call already runs in the browser. frontend/src/services/marketService.ts calls the Wikimedia Client directly and prices with computeContractPrice client-side, and useTeamLineup already fetches link sets through the client's 7-day cache. The Genie inherits all of it.

BROWSER  (useGenie composable)
  parse   POST /api/v1/me/genie-seeds { query }
          ← { keywords, anchors: [...] }
  seed    S1 = searchTitles("linksto:A linksto:B"), title + description
          S2 = searchTitles(keywords)             , always, never routed away
          S3 = outbound(A) ∩ outbound(B)          , from the cached link sets
          merge + dedupe, rank by mutual links, cap 40
  loop    POST /api/v1/me/genie-turns { query, history, candidates, bucket }
          ← { utterance, keep: [ids], options, kind, done }
  finish  survivors → fetchMarketArticlesByTitle → priced rows,
          re-ranked by (mutual links desc, price asc)

WORKER   (two routes, one model call each)
  env.AI.run("@cf/mistralai/mistral-small-3.1-24b-instruct", { messages })
  one subrequest · no D1 · no migration

Why parsing is its own call. A turn is defined over the Candidate Set, the bounded list of real titles the Genie narrows, seeded from search and, where the player named anchors, from articles linked with them, and there is no Candidate Set until the query has been read, so the parse cannot ride along with the first turn. Seeding on keywords alone instead would have the Genie open with a question about articles describing OpenAI rather than ones relating it to Portugal.

Why price is absent from the loop. searchTitles is the search call without the per-article view series, added for exactly this: pricing a candidate costs one 365-day pageview request, and the client caches those only as part of a whole search result. Pricing ~40 candidates up front would be ~40 requests before the first question, for figures that all but five of them never show. The loop ranks on link structure; (mutual links, price) is applied at finish, over at most five articles.

The model id and the ~40 cap are not arbitrary, both were fixed by live testing; see ADR 0006 → Provider and model. The 8B model that a cost-first reading would pick cannot hold the loop (it loses the correct answer ~1 session in 3); 24B survives 40/40 at ~77 neurons.

Keeping the Wikimedia calls in the browser is deliberate: the Workers Free plan allows 50 subrequests and 10 ms CPU per invocation, and the browser has neither limit and already holds the cache.

The llmClient seam ​

The AI binding is wrapped in a service under backend/src/services/, alongside wikimediaClient and githubClient, and is constructor-injected, the way every service here takes its dependencies:

ts
export class ArticleGenieService {
  constructor(private readonly llm: LlmClient = createLlmClient()) {}
}

Injection is not stylistic here. Under @cloudflare/vitest-pool-workers, vi.mock silently no-ops, a mocked module resolves to the real one without an error, so a test that appears to stub the model would quietly spend real neurons and assert against real output. The constructor parameter is the only reliable substitution point.

LlmClient exposes one method so the provider stays swappable:

ts
type LlmClient = { ask(messages: Message[]): Promise<string> };

Turn protocol ​

POST /api/v1/me/genie-turns, self-scoped, identity from the JWT, no playerId from the client (see API Naming Rules).

Request

FieldNotes
queryopening free text, first turn only; clamped to ~200 chars
historyprior {question, answer} pairs, questions only, never the flavour text
candidates{id, title, description}[], ids are small integers assigned by the client. The description is mandatory, see Descriptions carry recency
bucketa word, not a number, see Progress without numbers

Response

FieldNotes
utteranceone sentence: flavour plus the next question
keepnumeric ids that survive; numbers, not titles
optionsthe taps offered for this question; the client adds its own "not sure"
kindfilter or preference, a safety boundary, enforced server-side
donemodel signals it is confident enough to stop

What the backend enforces rather than asks for. Everything the model can get wrong is corrected against the request it was answering, because a prompt is a request and these are guarantees:

  • ids not in the supplied list are discarded (a made-up id has no article behind it);
  • kind: "preference" keeps every id, so such a question can only ever re-rank;
  • an unsure answer keeps every id, so a question the player could not answer never costs them the article;
  • a turn that would empty the set restores it: one wasted question beats a session ending with nothing;
  • a question already in the history earns one corrective retry, and if the model repeats itself again the turn is forced to done. A set already split on an answer cannot split on it twice, so a repeat is always a wasted question, and the player, who sees the same sentence twice, reads it as the Genie being stuck. Comparison is on the question rather than the utterance, since the flavour is display-only and never returns in the history; in practice a repeated question is what makes the repeated sentence.

The session is stateless, the client carries the history. This is safe because resetting the history is equivalent to starting a new game, which is already free; the abuse control is the rate limiter, not the protocol.

Numeric ids are load-bearing for cost. Output tokens cost far more than input, and the survivor list is returned every turn. Returning ids (~2 tokens each) rather than full titles (~6 tokens) roughly halves session cost, the difference between the chosen design and one that echoes titles back.

Descriptions carry recency ​

The candidate listing sent to the model must include each article's one-line description, not just its title. The game trades on trending articles, which are mostly newer than the model's training cutoff, it cannot know a 2026 prime minister or a last-season footballer by name. It does not need to: it classifies from the description. Measured on the real current top-read set, single turn "is it a person?": with descriptions the model keeps 100% of the people and drops 100% of the non-people; titles only drops to 84.6% recall, silently losing people it has never heard of. A five-turn interrogation toward a post-cutoff target (a 2007-born footballer) survived 5/5 with descriptions, including a "born after 2000?" question answered by reading "born 2007" from the description. Consequently, interrogation questions must be answerable from title + description (person / nation / woman / sport / century), not from world knowledge the model may lack.

The two question kinds ​

The model may ask either kind, and the distinction is a safety boundary:

KindExampleEffectCan lose the answer?
Filter"Is it a person?"partitions the listYes, see Risks
Preference"Something niche, or something famous?"re-ranks only, drops nothingNo

Preference questions are strictly safe and map directly onto the price axis players care about, so the prompt should favour them once the list is already small.

Adaptive stop ​

Turns scale with the candidate count. The loop stops when survivors ≤ 5, when the model sets done, or at the soft cap of 10 turns (with one optional "+5" extension).

Seeded candidatesExpected questions
~17 (anchored, narrow)~2
~40 (recall query, at the cap)~4–5

Showing five results is a fine outcome; the loop is not required to reach exactly one. A single result is shown only when the model is confident. The recall seed is capped at ~40 because the search API puts the intended article in roughly the top three, so 40 reliably contains it while keeping per-turn cost and the model's attention load low.

Progress without numbers ​

The player must never see a raw candidate count, it reads as debug output and breaks character. The frontend maps the exact count to a bucket word (vast, many, a dozen or so, a handful, almost there) and passes only that word into the prompt; the model weaves it into its utterance:

"Mhh, interesting, I'm down to a handful. Did she work on spaceflight?"

The model therefore never sees a number and cannot contradict one. The flavour is display-only: it is not written back into history, so it never re-enters the input on later turns.

Quota exhaustion, "the genie is asleep" ​

On the Workers Free plan the 10,000 neurons/day allocation is a hard ceiling and further calls fail. There is no counter to maintain and no way to be billed:

ts
try {
  return await this.llm.ask(messages);
} catch {
  return failure(GENIE_ASLEEP);
}

Any model failure, quota, transport, or an unparseable response after one retry, maps to the same player-facing state: a brief popup saying the genie is asleep, and the panel dismisses to the ordinary search bar. The feature is purely additive, so degrading it never blocks buying an article.

Neuron budget ​

Measured on the chosen 24B model (5-turn interrogation, candidate list capped at ~40):

Scenarioneurons/sessionsessions/day
Recall, ~40 candidates, ~5 turns (24B)~77~130
Same on 8B, but 8B loses the answer 1/3 of the time~22~450
Same on 70B, works, but no better than 24B~102~98

If budget ever needs reclaiming, tighten the candidate cap or the extension, or drop a Gemini fallback in behind the seam. The flavour narration is a small fraction of a session and is not worth cutting.

Input safety ​

The opening query is the only free-text field that reaches the prompt; every later answer is a tap. That keeps the injection surface to one clamped field.

  • Clamp query to ~200 chars, and the candidate list to ~40 entries (also the design cap).
  • Reject oversized payloads, do not truncate: silent truncation hides a client bug and makes the model's answer depend on invisible state.
  • Constrain the model to return ids drawn only from the supplied list; discard any id that is not in it rather than trusting the response.
  • Rate-limit per player with an unsafe ratelimit binding, following the REPORT_RATE_LIMITER pattern already in wrangler.jsonc. Size it against a real session, not just abuse: a session fires several requests within a minute, so a tight per-minute cap throttles legitimate play.

Risks ​

  • Classification error is the real failure mode, not hallucination. A filter question the model answers wrongly deletes the right article from the survivor set, and the Genie cannot recover. This is the single reason the 24B model is mandated over 8B (see the diagram note). Further mitigations: allow an unsure bucket that is never dropped; if survivors reach zero, restore the previous turn's list and tell the model it went wrong; prefer preference questions once the list is small.
  • Multi-word anchors must be underscored or quoted. linksto:Formula One silently parses as linksto:Formula plus free text and returns unrelated results; linksto:Formula_One is correct. Enforce in the prompt and defensively in code.
  • Redirect titles. prop=links returns targets as written ("ArXiv (identifier)"), so link data must be resolved to canonical titles before matching against article titles.

Optional by construction ​

The Genie is the only feature that needs a Cloudflare account, so a deployment without one carries no trace of it rather than a button that can only fail.

The local environment in wrangler.jsonc therefore declares no ai binding, for the same reason test doesn't: Workers AI has no local simulator, so the binding's mere presence makes Wrangler open a remote proxy session and demand credentials. Declaring it would mean a fresh clone cannot start the Worker at all, the whole app held hostage by one optional feature. npm run devgenie (the local-genie environment) is the opt-in for whoever is actually working on it, ./gradlew up in Docker, where the credential must be a CLOUDFLARE_API_TOKEN rather than a wrangler login session (see Running FantasyWiki in Docker).

The absence then propagates in one direction, from the binding outwards:

LayerBehaviour when env.AI is undefined
GET /api/v1/sessionfeatures.articleGenie: false
POST /api/v1/me/genie-seeds, /genie-turns503 GENIE_ASLEEP, checked before the rate limiter so a build that never offers the feature cannot spend the player's quota on it
MarketPage.vueNo trigger button and no modal, the app store's isArticleGenieAvailable gates both

Two points of design, both deliberate:

  • The flag is read off the binding, not off a var. A deployment cannot claim a capability it has no credentials for, so config and reality cannot drift apart.
  • It rides on the session rather than on a /api/v1/features route of its own, because /api/v1/session is the one /api/* path MSW passes through to the real backend (frontend/src/mocks/handlers.ts). In devMock, the mode someone running the app for the first time uses, a dedicated route would be answered by a handler that cannot know what the Worker is bound to.

The reuse of GENIE_ASLEEP is not laziness: quota exhaustion and an unbound model mean the same thing to a player, and the frontend already dismisses that code to the ordinary search bar.

Testing ​

  • Backend: inject a stub LlmClient; never rely on vi.mock under the Workers pool. Cover: valid JSON, malformed JSON then a successful retry, malformed twice → asleep, ids outside the candidate list discarded, oversized payload rejected.
  • The AI binding is absent from the test environment in wrangler.jsonc, on purpose. Workers AI has no local simulator, so declaring it makes the pool open a remote proxy session and demand a CLOUDFLARE_API_TOKEN, which CI, running ./gradlew check, does not have. With the binding declared the entire backend suite fails to start, not just the Genie's tests. Route tests supply env.AI themselves, which is what they should do anyway.
  • A deployment with no binding at all is itself a case to cover. test omits ai, so a route test that simply doesn't stub env.AI is that deployment, see genieRoute.integration.test.ts and routes/session.spec.ts.
  • Frontend: MSW with onUnhandledRequest: "error", per frontend/src/tests/setup.ts. Cover: seeding merge and dedupe, ranking order, the asleep popup, and dismissal to the search bar.
  • Model fidelity: already validated against the live binding (the load-bearing assumption behind the whole loop). A scripted 5-turn interrogation over ~40 real candidates, feeding the model's own surviving subset forward each turn, keeps the planted correct answer 40/40 on 24B and loses it ~1/3 of the time on 8B; recency holds when descriptions are included (see Descriptions carry recency). Re-run this probe whenever the model id or the prompt changes, it is the cheapest guard against a regression that would silently start deleting correct answers.

Built from the repository's own documentation. Source on GitHub.