feat(ai): ground deck suggestions in the deck's actual plan (#295) - #349
Merged
Conversation
The AI picks read as generic goodstuff, and the grounding explains why. Three defects, each fixed:
**The commander's ability was truncated to its first line** (`oracle.split("\n")[0][:160]`). A
commander's engine is routinely on line two or three — an activated ability, a death trigger — so
for a large class of decks the model never saw the thing the deck is built around. Full text now,
bounded at 600 chars so a wall of reminder text can't crowd out the rest of the prompt.
**Candidates were bare names.** The model was asked to reason about synergy while being told only
what the cards are called. Each candidate now arrives as `Name — role | shared keywords | what it
does`, so a pick can be reasoned about rather than recognised.
**The shortlist was arbitrary.** Only so many candidates fit in a prompt, and the old code took
`list(pool.values())[:60]` — the first 60 by oracle id. Most of the collection was never seen, and
which part *was* seen had nothing to do with the deck. The shortlist is now the 40 best-fitting
cards, scored with #294's `deck_synergy` — fewer candidates, but the right ones, and the freed
tokens pay for the annotations.
The prompt now orders its criteria explicitly (commander's specific ability, then themes, then thin
roles), tells the model not to suggest powerful cards that don't advance the plan, and requires each
reason to name the specific interaction rather than "solid value". The deck's creature types are
grounded too, via the same deck-derived tribe detection.
Validation is unchanged in reach: card names are still checked against the **whole** owned pool, not
the shortlist, so a card the model knows from elsewhere still resolves if you own it. There is a
test for exactly that, because narrowing what we send is precisely the change that could have
narrowed what we accept.
`llm._candidate_pool` is gone — the shortlist path replaced its only caller, and a second pool
builder left lying around is how the two paths would drift.
11 new tests; suite 1285 passing at 100% coverage. Closes the Decks & EDH tooling milestone
alongside #294.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LxrePqFWyzQTcWaroSk4sH
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #295. With #294 merged, this closes out the Decks & EDH tooling milestone.
Why the picks were generic
The complaint in #295 is that AI suggestions return goodstuff rather than cards that fit the deck. The grounding explains it — three defects, each fixed here:
1. The commander's ability was truncated to its first line.
A commander's engine is routinely on line two or three — an activated ability, a death trigger. For a large class of decks the model never saw the thing the deck is built around, then got asked to suggest cards that synergise with it. Now the full text, joined onto one line and bounded at 600 chars so a wall of reminder text can't crowd out the rest of the prompt.
2. Candidates were bare names. The model was asked to reason about synergy while being told only what the cards were called. Each candidate now arrives as
Name — role | shared keywords | what it does.3. The shortlist was arbitrary. Only so many candidates fit in a prompt, and the old code took
list(pool.values())[:60]— the first 60 by oracle id. Most of the collection was never seen, and which part was seen had nothing to do with the deck. It's now the 40 best-fitting cards, scored with #294'sdeck_synergy. Fewer candidates, but the right ones — and the freed tokens pay for the annotations.Prompt
Criteria are now explicitly ordered — the commander's specific ability, then the deck's themes and creature types, then thin roles — with an instruction not to suggest powerful cards that don't advance the plan, and a requirement that each reason name the specific interaction rather than calling something "solid value".
What deliberately did not change
Validation still covers the whole owned pool, not the shortlist. Narrowing what we send is exactly the change that could have accidentally narrowed what we accept, turning a legitimate pick into a "hallucination". There's a test for it: the model names a card that wasn't among the 40, and it still resolves.
llm._candidate_poolis deleted rather than left behind — the shortlist path replaced its only caller, and a second pool builder lying around is how two paths drift.Verification
ruffandmkdocs --strictclean. No migration.🤖 Generated with Claude Code
https://claude.ai/code/session_01LxrePqFWyzQTcWaroSk4sH