Codex Claude Opus 4.8 commited on
Commit
b483ca7
·
1 Parent(s): 8f039b3

Trim prefetch to 1 pack, log model-pack failures, JSON-constrained cards, rotate fallback names

Browse files
ai_runtime.py CHANGED
@@ -43,7 +43,7 @@ def local_ai_env(card_port: int = DEFAULT_CARD_PORT) -> dict[str, str]:
43
  "TABRAS_CARD_ENDPOINT": chat_endpoint(card_port),
44
  "TABRAS_CARD_MODEL": "minicpm-v-4.6-q4",
45
  "TABRAS_CARD_TEMPERATURE": "0.7",
46
- "TABRAS_CARD_MAX_TOKENS": "192",
47
  "TABRAS_AI_BOSS": "1",
48
  "TABRAS_BOSS_BACKEND": "mlx",
49
  "TABRAS_BOSS_MODEL": DEFAULT_BOSS_MODEL,
 
43
  "TABRAS_CARD_ENDPOINT": chat_endpoint(card_port),
44
  "TABRAS_CARD_MODEL": "minicpm-v-4.6-q4",
45
  "TABRAS_CARD_TEMPERATURE": "0.7",
46
+ "TABRAS_CARD_MAX_TOKENS": "256",
47
  "TABRAS_AI_BOSS": "1",
48
  "TABRAS_BOSS_BACKEND": "mlx",
49
  "TABRAS_BOSS_MODEL": DEFAULT_BOSS_MODEL,
clients.py CHANGED
@@ -51,7 +51,7 @@ def configure_mode() -> str:
51
  os.environ.setdefault("TABRAS_ART_MODEL", DEFAULT_ART_MODEL)
52
  os.environ.setdefault("TABRAS_AI_BOSS", "1")
53
  os.environ.setdefault("TABRAS_CARD_TEMPERATURE", "0.7")
54
- os.environ.setdefault("TABRAS_CARD_MAX_TOKENS", "192")
55
  os.environ.setdefault("TABRAS_BOSS_MAX_TOKENS", "96")
56
  os.environ.setdefault("TABRAS_ART_STEPS", "4")
57
  return mode
@@ -61,13 +61,12 @@ def configure_mode() -> str:
61
  def card_client_from_env() -> CardPackClient | None:
62
  if os.environ.get("TABRAS_CARD_BACKEND") == "llamacpp":
63
  return LlamaCppCardClient(
64
- LocalChatClient(
65
  endpoint=os.environ.get("TABRAS_CARD_ENDPOINT", DEFAULT_LLAMA_CARD_ENDPOINT),
66
  model=os.environ.get("TABRAS_CARD_MODEL", DEFAULT_CARD_MODEL),
67
  timeout_seconds=int(os.environ.get("TABRAS_CARD_TIMEOUT", "60")),
68
  temperature=float(os.environ.get("TABRAS_CARD_TEMPERATURE", "0.0")),
69
  max_tokens=int(os.environ.get("TABRAS_CARD_MAX_TOKENS", "320")),
70
- enable_thinking=False,
71
  )
72
  )
73
  if os.environ.get("TABRAS_CARD_BACKEND") == "transformers":
 
51
  os.environ.setdefault("TABRAS_ART_MODEL", DEFAULT_ART_MODEL)
52
  os.environ.setdefault("TABRAS_AI_BOSS", "1")
53
  os.environ.setdefault("TABRAS_CARD_TEMPERATURE", "0.7")
54
+ os.environ.setdefault("TABRAS_CARD_MAX_TOKENS", "256")
55
  os.environ.setdefault("TABRAS_BOSS_MAX_TOKENS", "96")
56
  os.environ.setdefault("TABRAS_ART_STEPS", "4")
57
  return mode
 
61
  def card_client_from_env() -> CardPackClient | None:
62
  if os.environ.get("TABRAS_CARD_BACKEND") == "llamacpp":
63
  return LlamaCppCardClient(
64
+ LocalJsonChatClient(
65
  endpoint=os.environ.get("TABRAS_CARD_ENDPOINT", DEFAULT_LLAMA_CARD_ENDPOINT),
66
  model=os.environ.get("TABRAS_CARD_MODEL", DEFAULT_CARD_MODEL),
67
  timeout_seconds=int(os.environ.get("TABRAS_CARD_TIMEOUT", "60")),
68
  temperature=float(os.environ.get("TABRAS_CARD_TEMPERATURE", "0.0")),
69
  max_tokens=int(os.environ.get("TABRAS_CARD_MAX_TOKENS", "320")),
 
70
  )
71
  )
72
  if os.environ.get("TABRAS_CARD_BACKEND") == "transformers":
generator.py CHANGED
@@ -8,7 +8,7 @@ from pathlib import Path
8
  from typing import Any, Protocol, Sequence
9
 
10
  from budget import Card, CardSpec, EffectPlan, cost_card
11
- from local_llm import LocalChatClient
12
  from primitives import PrimitiveId, School, school_bias, school_primitives
13
 
14
 
@@ -57,7 +57,7 @@ class CodexCardClient:
57
 
58
  @dataclass(frozen=True)
59
  class MiniCPMCardClient:
60
- chat: LocalChatClient
61
 
62
  # Return one raw card payload from a local MiniCPM endpoint.
63
  def create_card(self, payload: dict[str, Any]) -> dict[str, Any]:
@@ -74,7 +74,7 @@ class MiniCPMCardClient:
74
 
75
  @dataclass(frozen=True)
76
  class LlamaCppCardClient:
77
- chat: LocalChatClient
78
 
79
  # Return one raw card payload from a llama.cpp MiniCPM endpoint.
80
  def create_card(self, payload: dict[str, Any]) -> dict[str, Any]:
@@ -399,7 +399,7 @@ def llamacpp_card_prompt(payload: dict[str, Any], pack_cards: Sequence[dict[str,
399
 
400
 
401
  # Generate one llama.cpp card with one corrective retry; quick mode takes the first card.
402
- def generate_llamacpp_card(chat: LocalChatClient, payload: dict[str, Any], pack_cards: Sequence[dict[str, Any]], focus: str = "") -> dict[str, Any]:
403
  prompt = llamacpp_card_prompt(payload, pack_cards, focus)
404
  card = parse_llamacpp_card_text(chat.complete(card_system_prompt(), prompt), payload, pack_cards, discard_bad_text=False)
405
  need = llama_candidate_need(payload, pack_summary(pack_cards))
@@ -855,14 +855,42 @@ def fallback_art_prompt(payload: dict[str, Any]) -> str:
855
  # Extract the first JSON object from model text.
856
  def extract_json_object(text: str) -> str:
857
  start = text.find("{")
858
- end = text.rfind("}")
859
- if start == -1 or end == -1 or end < start:
860
  raise ValueError("Codex response did not contain a JSON object")
861
- candidate = balance_json_closers(text[start : end + 1])
 
862
  json.loads(candidate)
863
  return candidate
864
 
865
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
866
  # Append missing JSON object or array closers.
867
  def balance_json_closers(text: str) -> str:
868
  stack: list[str] = []
 
8
  from typing import Any, Protocol, Sequence
9
 
10
  from budget import Card, CardSpec, EffectPlan, cost_card
11
+ from local_llm import ChatCompleter
12
  from primitives import PrimitiveId, School, school_bias, school_primitives
13
 
14
 
 
57
 
58
  @dataclass(frozen=True)
59
  class MiniCPMCardClient:
60
+ chat: ChatCompleter
61
 
62
  # Return one raw card payload from a local MiniCPM endpoint.
63
  def create_card(self, payload: dict[str, Any]) -> dict[str, Any]:
 
74
 
75
  @dataclass(frozen=True)
76
  class LlamaCppCardClient:
77
+ chat: ChatCompleter
78
 
79
  # Return one raw card payload from a llama.cpp MiniCPM endpoint.
80
  def create_card(self, payload: dict[str, Any]) -> dict[str, Any]:
 
399
 
400
 
401
  # Generate one llama.cpp card with one corrective retry; quick mode takes the first card.
402
+ def generate_llamacpp_card(chat: ChatCompleter, payload: dict[str, Any], pack_cards: Sequence[dict[str, Any]], focus: str = "") -> dict[str, Any]:
403
  prompt = llamacpp_card_prompt(payload, pack_cards, focus)
404
  card = parse_llamacpp_card_text(chat.complete(card_system_prompt(), prompt), payload, pack_cards, discard_bad_text=False)
405
  need = llama_candidate_need(payload, pack_summary(pack_cards))
 
855
  # Extract the first JSON object from model text.
856
  def extract_json_object(text: str) -> str:
857
  start = text.find("{")
858
+ if start == -1:
 
859
  raise ValueError("Codex response did not contain a JSON object")
860
+ end = json_object_end(text, start)
861
+ candidate = balance_json_closers(text[start:end])
862
  json.loads(candidate)
863
  return candidate
864
 
865
 
866
+ # Return the end offset of the first balanced JSON object.
867
+ def json_object_end(text: str, start: int) -> int:
868
+ stack: list[str] = []
869
+ in_string = False
870
+ escaped = False
871
+ for index, char in enumerate(text[start:], start):
872
+ if escaped:
873
+ escaped = False
874
+ continue
875
+ if char == "\\" and in_string:
876
+ escaped = True
877
+ continue
878
+ if char == "\"":
879
+ in_string = not in_string
880
+ continue
881
+ if in_string:
882
+ continue
883
+ if char == "{":
884
+ stack.append("}")
885
+ elif char == "[":
886
+ stack.append("]")
887
+ elif stack and char == stack[-1]:
888
+ stack.pop()
889
+ if not stack:
890
+ return index + 1
891
+ return len(text)
892
+
893
+
894
  # Append missing JSON object or array closers.
895
  def balance_json_closers(text: str) -> str:
896
  stack: list[str] = []
local_llm.py CHANGED
@@ -51,6 +51,9 @@ class LocalJsonChatClient:
51
  return parse_chat_response(json.loads(response.read().decode("utf-8")))
52
 
53
 
 
 
 
54
  @dataclass(frozen=True)
55
  class LocalCompletionClient:
56
  endpoint: str
 
51
  return parse_chat_response(json.loads(response.read().decode("utf-8")))
52
 
53
 
54
+ ChatCompleter = LocalChatClient | LocalJsonChatClient
55
+
56
+
57
  @dataclass(frozen=True)
58
  class LocalCompletionClient:
59
  endpoint: str
tests/test_ai_runtime.py CHANGED
@@ -24,7 +24,7 @@ def test_local_ai_env(monkeypatch) -> None:
24
  env = local_ai_env(9001)
25
  assert env["TABRAS_CARD_BACKEND"] == "llamacpp"
26
  assert env["TABRAS_CARD_ENDPOINT"] == "http://127.0.0.1:9001/v1/chat/completions"
27
- assert env["TABRAS_CARD_MAX_TOKENS"] == "192"
28
  assert env["TABRAS_CARD_TEMPERATURE"] == "0.7"
29
  assert env["TABRAS_AI_BOSS"] == "1"
30
  assert env["TABRAS_BOSS_BACKEND"] == "mlx"
 
24
  env = local_ai_env(9001)
25
  assert env["TABRAS_CARD_BACKEND"] == "llamacpp"
26
  assert env["TABRAS_CARD_ENDPOINT"] == "http://127.0.0.1:9001/v1/chat/completions"
27
+ assert env["TABRAS_CARD_MAX_TOKENS"] == "256"
28
  assert env["TABRAS_CARD_TEMPERATURE"] == "0.7"
29
  assert env["TABRAS_AI_BOSS"] == "1"
30
  assert env["TABRAS_BOSS_BACKEND"] == "mlx"
tests/test_clients.py CHANGED
@@ -2,7 +2,7 @@ from clients import art_client_from_env, boss_client_from_env, card_client_from_
2
  from art import DiffusersImageClient, LazyArtClient
3
  from boss import NemotronBossClient
4
  from generator import LlamaCppCardClient, MiniCPMCardClient
5
- from local_llm import LocalChatClient, LocalCompletionClient, nemotron_prompt
6
 
7
 
8
  class FakeChat:
@@ -50,11 +50,10 @@ def test_card_client_from_env_llamacpp(monkeypatch) -> None:
50
  monkeypatch.setenv("TABRAS_CARD_MODEL", "mini-q4")
51
  client = card_client_from_env()
52
  assert isinstance(client, LlamaCppCardClient)
53
- assert isinstance(client.chat, LocalChatClient)
54
  assert client.chat.endpoint == "http://cards"
55
  assert client.chat.model == "mini-q4"
56
  assert client.chat.max_tokens == 320
57
- assert client.chat.enable_thinking is False
58
 
59
 
60
  # Verify boss client is disabled unless enabled.
 
2
  from art import DiffusersImageClient, LazyArtClient
3
  from boss import NemotronBossClient
4
  from generator import LlamaCppCardClient, MiniCPMCardClient
5
+ from local_llm import LocalCompletionClient, LocalJsonChatClient, nemotron_prompt
6
 
7
 
8
  class FakeChat:
 
50
  monkeypatch.setenv("TABRAS_CARD_MODEL", "mini-q4")
51
  client = card_client_from_env()
52
  assert isinstance(client, LlamaCppCardClient)
53
+ assert isinstance(client.chat, LocalJsonChatClient)
54
  assert client.chat.endpoint == "http://cards"
55
  assert client.chat.model == "mini-q4"
56
  assert client.chat.max_tokens == 320
 
57
 
58
 
59
  # Verify boss client is disabled unless enabled.
tests/test_generator.py CHANGED
@@ -399,6 +399,12 @@ def test_extract_json_object() -> None:
399
  extract_json_object("no json")
400
 
401
 
 
 
 
 
 
 
402
  # Verify JSON extraction repairs missing closing delimiters.
403
  def test_extract_json_object_repairs_missing_closer() -> None:
404
  assert extract_json_object('{"cards": [{"name": "A"}]') == '{"cards": [{"name": "A"}]}'
 
399
  extract_json_object("no json")
400
 
401
 
402
+ # Verify JSON extraction ignores later objects in chatty model output.
403
+ def test_extract_json_object_uses_first_balanced_object() -> None:
404
+ text = 'first {"name": "A", "effects": []} then {"name": "B"}'
405
+ assert extract_json_object(text) == '{"name": "A", "effects": []}'
406
+
407
+
408
  # Verify JSON extraction repairs missing closing delimiters.
409
  def test_extract_json_object_repairs_missing_closer() -> None:
410
  assert extract_json_object('{"cards": [{"name": "A"}]') == '{"cards": [{"name": "A"}]}'
tests/test_ui.py CHANGED
@@ -15,6 +15,7 @@ from ui import (
15
  fallback_pack,
16
  finish_opening_draft,
17
  log_html,
 
18
  mana_html,
19
  new_run,
20
  new_run_shell,
@@ -22,6 +23,7 @@ from ui import (
22
  pass_turn_steps,
23
  pending_tokens_html,
24
  play_hand_card,
 
25
  queue_next_pack,
26
  refresh_art,
27
  )
@@ -57,6 +59,15 @@ def test_fire_fallback_pack_rotates_names() -> None:
57
  assert all("card" not in card.art_prompt.lower() for card in first + second)
58
 
59
 
 
 
 
 
 
 
 
 
 
60
  # Verify a new run starts in draft state with three clickable cards.
61
  def test_new_run_starts_draft() -> None:
62
  state = new_run("Ada", "anime", "ice", seed=1)
@@ -446,6 +457,12 @@ class ForgePackClient:
446
  return {"cards": [card(i) for i in range(payload["pack_size"])]}
447
 
448
 
 
 
 
 
 
 
449
  class FakeArtClient:
450
  # Return a stable URI for each generated art prompt.
451
  def create_art(self, prompt: str) -> str:
@@ -464,18 +481,27 @@ class GatedArtClient:
464
 
465
 
466
  # Verify a new run pre-forges candidate packs and the boss deck in the background.
467
- def test_new_run_warms_forge() -> None:
 
468
  forge.reset()
469
  client = ForgePackClient()
470
  state = new_run("Ada", "anime", "ice", client, seed=3)
471
  forge.drain()
472
- # pack 1 + three speculative next packs; the boss deck is deferred (draft_step >= 2)
473
  # so it no longer competes with the first visible pack.
474
- assert client.calls == 4
475
  picked = choose_draft_card(state, 0, client)
476
  assert all(card.name.startswith("Forged") for card in picked.current_pack)
477
 
478
 
 
 
 
 
 
 
 
 
479
  # Verify battle start consumes the pre-forged boss deck.
480
  def test_battle_uses_forged_boss_deck() -> None:
481
  forge.reset()
 
15
  fallback_pack,
16
  finish_opening_draft,
17
  log_html,
18
+ make_pack,
19
  mana_html,
20
  new_run,
21
  new_run_shell,
 
23
  pass_turn_steps,
24
  pending_tokens_html,
25
  play_hand_card,
26
+ prefetch_pack_limit,
27
  queue_next_pack,
28
  refresh_art,
29
  )
 
59
  assert all("card" not in card.art_prompt.lower() for card in first + second)
60
 
61
 
62
+ # Verify model pack failures are visible in server logs before fallback.
63
+ def test_make_pack_logs_model_failure(capsys) -> None:
64
+ pack = make_pack(BrokenPackClient(), None, "fire", "dark fantasy", (), 1)
65
+ captured = capsys.readouterr()
66
+ assert len(pack) == CARD_PANEL_COUNT
67
+ assert "Tabras model pack failed" in captured.err
68
+ assert "RuntimeError: bad json" in captured.err
69
+
70
+
71
  # Verify a new run starts in draft state with three clickable cards.
72
  def test_new_run_starts_draft() -> None:
73
  state = new_run("Ada", "anime", "ice", seed=1)
 
457
  return {"cards": [card(i) for i in range(payload["pack_size"])]}
458
 
459
 
460
+ class BrokenPackClient:
461
+ # Raise like a model response that cannot be parsed or costed.
462
+ def create_pack(self, payload):
463
+ raise RuntimeError("bad json")
464
+
465
+
466
  class FakeArtClient:
467
  # Return a stable URI for each generated art prompt.
468
  def create_art(self, prompt: str) -> str:
 
481
 
482
 
483
  # Verify a new run pre-forges candidate packs and the boss deck in the background.
484
+ def test_new_run_warms_forge(monkeypatch) -> None:
485
+ monkeypatch.delenv("TABRAS_PREFETCH_PACKS", raising=False)
486
  forge.reset()
487
  client = ForgePackClient()
488
  state = new_run("Ada", "anime", "ice", client, seed=3)
489
  forge.drain()
490
+ # pack 1 + one speculative next pack; the boss deck is deferred (draft_step >= 2)
491
  # so it no longer competes with the first visible pack.
492
+ assert client.calls == 2
493
  picked = choose_draft_card(state, 0, client)
494
  assert all(card.name.startswith("Forged") for card in picked.current_pack)
495
 
496
 
497
+ # Verify the speculative draft fanout can be tuned without crashing.
498
+ def test_prefetch_pack_limit_env(monkeypatch) -> None:
499
+ monkeypatch.setenv("TABRAS_PREFETCH_PACKS", "0")
500
+ assert prefetch_pack_limit() == 0
501
+ monkeypatch.setenv("TABRAS_PREFETCH_PACKS", "bad")
502
+ assert prefetch_pack_limit() == 1
503
+
504
+
505
  # Verify battle start consumes the pre-forged boss deck.
506
  def test_battle_uses_forged_boss_deck() -> None:
507
  forge.reset()
ui.py CHANGED
@@ -1,6 +1,8 @@
1
  from collections.abc import Callable, Iterator
2
  from dataclasses import dataclass, replace
 
3
  from random import Random
 
4
  from typing import Sequence
5
 
6
  import forge
@@ -281,11 +283,11 @@ def pack_maker(
281
  return lambda: make_pack(client, None, state.school, state.world, state.player_deck, cost, state.draft_anchors)
282
 
283
 
284
- # Pre-generate every candidate next pack while the player reads the current one.
285
  def prefetch_next_packs(state: RunState, client: CardPackClient | None, art_client: ArtClient | None) -> None:
286
  if client is None or state.draft_step >= len(state.draft_order):
287
  return
288
- for index in range(len(state.current_pack)):
289
  nxt = apply_pick(state, index)
290
  if nxt.draft_step >= len(nxt.draft_order):
291
  continue
@@ -293,6 +295,14 @@ def prefetch_next_packs(state: RunState, client: CardPackClient | None, art_clie
293
  forge.submit(pack_key(nxt), pack_maker(client, art_client, nxt, cost))
294
 
295
 
 
 
 
 
 
 
 
 
296
  # Return the forge key for the boss deck of one run.
297
  def enemy_deck_key(state: RunState) -> tuple:
298
  return ("enemy", state.enemy_school, state.world, state.enemy_seed)
@@ -444,8 +454,8 @@ def make_pack(
444
  try:
445
  pack = generate_pack(client, school, world, current_deck, cost, draft_anchors=anchors, quick=quick)
446
  return collect_ready_cards(pack)
447
- except Exception:
448
- pass
449
  pack = fallback_pack(school, world, cost, anchors, current_deck)
450
  return collect_ready_cards(pack)
451
 
 
1
  from collections.abc import Callable, Iterator
2
  from dataclasses import dataclass, replace
3
+ import os
4
  from random import Random
5
+ import sys
6
  from typing import Sequence
7
 
8
  import forge
 
283
  return lambda: make_pack(client, None, state.school, state.world, state.player_deck, cost, state.draft_anchors)
284
 
285
 
286
+ # Pre-generate a bounded number of next-pack branches while the player reads.
287
  def prefetch_next_packs(state: RunState, client: CardPackClient | None, art_client: ArtClient | None) -> None:
288
  if client is None or state.draft_step >= len(state.draft_order):
289
  return
290
+ for index in range(min(len(state.current_pack), prefetch_pack_limit())):
291
  nxt = apply_pick(state, index)
292
  if nxt.draft_step >= len(nxt.draft_order):
293
  continue
 
295
  forge.submit(pack_key(nxt), pack_maker(client, art_client, nxt, cost))
296
 
297
 
298
+ # Return how many possible next draft branches to forge speculatively.
299
+ def prefetch_pack_limit() -> int:
300
+ try:
301
+ return max(0, int(os.environ.get("TABRAS_PREFETCH_PACKS", "1")))
302
+ except ValueError:
303
+ return 1
304
+
305
+
306
  # Return the forge key for the boss deck of one run.
307
  def enemy_deck_key(state: RunState) -> tuple:
308
  return ("enemy", state.enemy_school, state.world, state.enemy_seed)
 
454
  try:
455
  pack = generate_pack(client, school, world, current_deck, cost, draft_anchors=anchors, quick=quick)
456
  return collect_ready_cards(pack)
457
+ except Exception as exc:
458
+ print(f"Tabras model pack failed; using fallback: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
459
  pack = fallback_pack(school, world, cost, anchors, current_deck)
460
  return collect_ready_cards(pack)
461