Skip to content

Commit a42b7de

Browse files
committed
fix: enforce deck AI response invariants
1 parent 48ba8b4 commit a42b7de

5 files changed

Lines changed: 89 additions & 14 deletions

File tree

‎lib/manavault/ai.ex‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,12 @@ defmodule Manavault.AI do
5353
{:ok, provider} <- Provider.module(settings.provider),
5454
payload <- DeckAnalysis.payload(deck, Catalog.deck_cards(deck)),
5555
{:ok, provider_result} <- provider.analyze_deck(settings, payload),
56-
{:ok, result} <- DeckAnalysis.normalize_result(provider_result, payload),
56+
{:ok, result} <-
57+
DeckAnalysis.normalize_result(
58+
provider_result,
59+
payload,
60+
settings.deck_analysis_instructions
61+
),
5762
attrs <- analysis_attrs(result, settings),
5863
{:ok, deck} <- Catalog.save_deck_analysis(deck, attrs) do
5964
{:ok, deck}

‎lib/manavault/ai/deck_analysis.ex‎

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ defmodule Manavault.AI.DeckAnalysis do
4242
Keep the final analysis compact: use one concise paragraph for each narrative field and three to
4343
five concise items for each standard list when the deck supports that many. Use deeper reasoning
4444
to improve the analysis rather than making the final response longer.
45+
Every suggested card must be legal in the deck's format. For Commander decks, its color identity
46+
must also be contained within deck.commander_color_identity. Omit any card whose legality or
47+
color identity you cannot verify rather than guessing.
4548
The facts object contains authoritative metadata calculated by ManaVault. Use its counts instead
4649
of recounting deck.cards.
4750
@@ -117,7 +120,7 @@ defmodule Manavault.AI.DeckAnalysis do
117120
"""
118121
end
119122

120-
def response_schema do
123+
def response_schema(custom_instructions \\ nil) do
121124
string = %{type: "string"}
122125
strings = %{type: "array", items: string}
123126
nullable_bracket = %{type: ["integer", "null"], minimum: 1, maximum: 5}
@@ -132,6 +135,11 @@ defmodule Manavault.AI.DeckAnalysis do
132135
}
133136
}
134137

138+
custom_sections =
139+
if custom_instructions?(custom_instructions),
140+
do: custom_sections,
141+
else: Map.put(custom_sections, :maxItems, 0)
142+
135143
%{
136144
type: "object",
137145
additionalProperties: false,
@@ -156,8 +164,15 @@ defmodule Manavault.AI.DeckAnalysis do
156164
}
157165
end
158166

159-
def normalize_result(result, payload) when is_map(result) do
167+
def normalize_result(result, payload, custom_instructions \\ nil)
168+
169+
def normalize_result(result, payload, custom_instructions) when is_map(result) do
160170
with {:ok, normalized} <- normalized_fields(result),
171+
normalized <-
172+
if(custom_instructions?(custom_instructions),
173+
do: normalized,
174+
else: Map.put(normalized, :custom_sections, [])
175+
),
161176
normalized <-
162177
Map.merge(normalized, %{
163178
official_bracket: value(result, :official_bracket),
@@ -168,7 +183,7 @@ defmodule Manavault.AI.DeckAnalysis do
168183
end
169184
end
170185

171-
def normalize_result(_result, _payload),
186+
def normalize_result(_result, _payload, _custom_instructions),
172187
do: {:error, "The AI provider returned an invalid analysis."}
173188

174189
def render_markdown(result) do
@@ -288,8 +303,27 @@ defmodule Manavault.AI.DeckAnalysis do
288303
if payload.deck.format == "commander" do
289304
with true <- valid_bracket?(official),
290305
true <- valid_bracket?(practical) do
291-
official = max(official, game_changer_minimum(payload.facts.game_changer_count))
292-
{:ok, Map.merge(result, %{official_bracket: official, play_bracket: practical})}
306+
game_changer_count = payload.facts.game_changer_count
307+
minimum = game_changer_minimum(game_changer_count)
308+
309+
result =
310+
Map.merge(result, %{
311+
official_bracket: max(official, minimum),
312+
play_bracket: practical
313+
})
314+
315+
result =
316+
if official < minimum do
317+
Map.put(
318+
result,
319+
:bracket_rationale,
320+
corrected_bracket_rationale(game_changer_count, minimum, practical)
321+
)
322+
else
323+
result
324+
end
325+
326+
{:ok, result}
293327
else
294328
_invalid -> {:error, "The AI provider returned an invalid Commander bracket."}
295329
end
@@ -315,6 +349,20 @@ defmodule Manavault.AI.DeckAnalysis do
315349
defp game_changer_minimum(count) when count <= 3, do: 3
316350
defp game_changer_minimum(_count), do: 4
317351

352+
defp corrected_bracket_rationale(game_changer_count, minimum, practical) do
353+
game_changers =
354+
if game_changer_count == 1,
355+
do: "1 Game Changer",
356+
else: "#{game_changer_count} Game Changers"
357+
358+
"The official Commander Brackets guidelines require at least Bracket #{minimum} because " <>
359+
"the deck contains #{game_changers}. Based on the rest of the list, it is expected to " <>
360+
"play like Bracket #{practical}."
361+
end
362+
363+
defp custom_instructions?(instructions),
364+
do: is_binary(instructions) and String.trim(instructions) != ""
365+
318366
defp section(title, content), do: "## #{title}\n\n#{content}"
319367

320368
defp list_section(title, items) do

‎lib/manavault/ai/providers/open_router.ex‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ defmodule Manavault.AI.Providers.OpenRouter do
5151
json_schema: %{
5252
name: "manavault_deck_analysis",
5353
strict: true,
54-
schema: DeckAnalysis.response_schema()
54+
schema: DeckAnalysis.response_schema(settings.deck_analysis_instructions)
5555
}
5656
}
5757
}
@@ -79,7 +79,7 @@ defmodule Manavault.AI.Providers.OpenRouter do
7979
%{role: "system", content: DeckQuestion.system_prompt()},
8080
%{role: "user", content: DeckQuestion.user_prompt(question, payload)}
8181
],
82-
max_tokens: 8_000,
82+
max_tokens: 20_000,
8383
temperature: 0.2,
8484
plugins: [%{id: "response-healing"}],
8585
response_format: %{

‎test/manavault/ai/deck_analysis_test.exs‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ defmodule Manavault.AI.DeckAnalysisTest do
2525
assert {:ok, result} = DeckAnalysis.normalize_result(@result, payload)
2626
assert result.official_bracket == 3
2727
assert result.play_bracket == 3
28+
assert result.bracket_rationale =~ "require at least Bracket 3"
29+
assert result.bracket_rationale =~ "1 Game Changer"
2830

2931
weaker = Map.put(@result, "play_bracket", 2)
3032
assert {:ok, weaker_result} = DeckAnalysis.normalize_result(weaker, payload)
@@ -69,7 +71,8 @@ defmodule Manavault.AI.DeckAnalysisTest do
6971
}
7072
])
7173

72-
assert {:ok, normalized} = DeckAnalysis.normalize_result(result, payload)
74+
assert {:ok, normalized} =
75+
DeckAnalysis.normalize_result(result, payload, "Add a budget upgrades section.")
7376

7477
assert normalized.custom_sections == [
7578
%{title: "Budget upgrades", content: "- Start with [[Counterspell]]."}
@@ -79,6 +82,25 @@ defmodule Manavault.AI.DeckAnalysisTest do
7982
"## Budget upgrades\n\n- Start with [[Counterspell]]."
8083
end
8184

85+
test "requires empty custom sections when no custom instructions exist" do
86+
schema = DeckAnalysis.response_schema()
87+
assert schema.properties.custom_sections.maxItems == 0
88+
89+
custom_schema =
90+
DeckAnalysis.response_schema("Add a budget section.").properties.custom_sections
91+
92+
refute Map.has_key?(custom_schema, :maxItems)
93+
94+
result =
95+
Map.put(@result, "custom_sections", [
96+
%{"title" => "Strengths", "content" => "Duplicated standard content."}
97+
])
98+
99+
payload = %{deck: %{format: "modern"}, facts: %{game_changer_count: 0}}
100+
assert {:ok, normalized} = DeckAnalysis.normalize_result(result, payload)
101+
assert normalized.custom_sections == []
102+
end
103+
82104
test "payload includes authoritative land metadata from counted deck zones" do
83105
deck = %Deck{name: "Land Count", format: "commander"}
84106

‎test/manavault/ai_test.exs‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ defmodule Manavault.AITest do
198198

199199
assert request["response_format"]["type"] == "json_schema"
200200
assert request["plugins"] == [%{"id" => "response-healing"}]
201-
assert request["max_tokens"] == 8_000
201+
assert request["max_tokens"] == 20_000
202202
refute Map.has_key?(request, "max_completion_tokens")
203203
refute Map.has_key?(request, "reasoning")
204204

@@ -281,7 +281,7 @@ defmodule Manavault.AITest do
281281
{:ok, request_body, conn} = Plug.Conn.read_body(conn)
282282
request = Jason.decode!(request_body)
283283
refute Map.has_key?(request, "reasoning")
284-
assert request["max_tokens"] == 8_000
284+
assert request["max_tokens"] == 20_000
285285

286286
json_response(conn, 200, %{
287287
"provider" => "Google",
@@ -294,7 +294,7 @@ defmodule Manavault.AITest do
294294
],
295295
"usage" => %{
296296
"prompt_tokens" => 12_345,
297-
"completion_tokens" => 8_000,
297+
"completion_tokens" => 20_000,
298298
"completion_tokens_details" => %{"reasoning_tokens" => 7_950}
299299
}
300300
})
@@ -311,7 +311,7 @@ defmodule Manavault.AITest do
311311
assert log =~ "finish_reason=\"length\""
312312
assert log =~ "native_finish_reason=\"MAX_TOKENS\""
313313
assert log =~ "prompt_tokens=12345"
314-
assert log =~ "completion_tokens=8000"
314+
assert log =~ "completion_tokens=20000"
315315
assert log =~ "reasoning_tokens=7950"
316316
assert log =~ "result=output_token_limit"
317317
refute log =~ "Do not include this question in logs."
@@ -334,7 +334,7 @@ defmodule Manavault.AITest do
334334
{:ok, request_body, conn} = Plug.Conn.read_body(conn)
335335
request = Jason.decode!(request_body)
336336
refute Map.has_key?(request, "reasoning")
337-
assert request["max_tokens"] == 8_000
337+
assert request["max_tokens"] == 20_000
338338

339339
json_response(conn, 200, %{
340340
"provider" => "Parasail",

0 commit comments

Comments
 (0)