feat(rag): Phase 3 — inject RAG cards into coach prompts (#132)

* feat(rag): add RAG card injection to prompt_template.py

* feat(rag): wire RAG query into coach_service.py

* test(rag): add Phase 3 RAG injection tests
This commit is contained in:
KoalaTrapLord authored and GitHub committed 2026-03-27 15:20:49 -04:00
1 parent bcf558418a
commit d0cdafa66a
3 files changed
+127 -3

No files matched your search

+20 -2
View File
@@ -252,9 +252,19 @@ class CoachService:
candidates[card_name] = [m.to_dict() for m in matches]
else:
logger.warning("Embeddings not loaded — skipping candidate search")
# Phase 3: RAG vector search for additional card context
rag_cards: List[dict] = []
try:
from services.rag_store import query_cards
query_text = f"{report.commander} {' '.join(report.colorIdentity)} commander deck"
rag_cards = query_cards(query_text, n=15, colors=report.colorIdentity)
logger.info("RAG returned %d cards for '%s'", len(rag_cards), deck_id)
except Exception as e:
logger.warning("RAG query failed (non-fatal): %s", e)
system_prompt = build_system_prompt(report, goals)
user_prompt = build_user_prompt(report, candidates)
user_prompt = build_user_prompt(report, candidates, rag_cards=rag_cards)
logger.info("Calling LLM for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
@@ -332,9 +342,17 @@ class CoachService:
if report is None:
raise ValueError(f"Deck report not found for: {deck_id}")
# Phase 3: light RAG query for quick digest too
rag_cards: List[dict] = []
try:
from services.rag_store import query_cards
query_text = f"{report.commander} {' '.join(report.colorIdentity)}"
rag_cards = query_cards(query_text, n=8, colors=report.colorIdentity)
except Exception:
pass
from .prompt_template import build_quick_system_prompt, build_user_prompt
system_prompt = build_quick_system_prompt(report, goals)
user_prompt = build_user_prompt(report, {}) # no candidates for speed
user_prompt = build_user_prompt(report, {}, rag_cards=rag_cards) # RAG-enriched quick digest # no candidates for speed
logger.info("Quick digest for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
+24 -1
View File
@@ -236,6 +236,9 @@ Avg Game Length: {avg_game_length:.1f} turns
### Underperforming Cards (candidates for cuts)
{underperformer_section}
### RAG-Retrieved Similar Cards (from ChromaDB)
{rag_section}
### Candidate Replacement Cards (from embeddings search)
{candidate_section}
@@ -336,6 +339,24 @@ def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
lines.append("cards NOT in this list if they better address the deck's weaknesses.")
return "\n".join(lines)
def _format_rag_cards(rag_cards: List[dict]) -> str:
"""Format RAG-retrieved cards from ChromaDB vector search."""
if not rag_cards:
return "No RAG results available."
lines = []
for card in rag_cards[:15]:
name = card.get("name", "Unknown")
types = card.get("types", "")
mv = card.get("mana_value", "?")
mc = card.get("mana_cost", "")
dist = card.get("distance", 0)
text = card.get("text", "")[:80]
lines.append(f"- {name} ({mc}, MV:{mv}, {types}) [distance: {dist:.3f}]")
if text:
lines.append(f" Oracle: {text}")
return "\n".join(lines)
def _format_matchups(report: DeckReport) -> str:
"""Format per-opponent matchup data."""
@@ -402,7 +423,8 @@ def build_quick_system_prompt(report: DeckReport,
def build_user_prompt(report: DeckReport,
candidates: Dict[str, List[dict]] = None) -> str:
candidates: Dict[str, List[dict]] = None,
rag_cards: List[dict] = None) -> str:
"""Build the user prompt with full deck report data."""
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "unknown"
type_parts = [f"{k}={v}" for k, v in report.structure.cardTypeCounts.items()] \
@@ -426,5 +448,6 @@ def build_user_prompt(report: DeckReport,
underperformer_section=_format_underperformers(
report.cards, report.underperformers),
candidate_section=_format_candidates(candidates or {}),
rag_section=_format_rag_cards(rag_cards or []),
combo_section=_format_combos(report),
)
+83
View File
@@ -0,0 +1,83 @@
"""Tests for RAG Phase 3 — coach prompt injection."""
import pytest
from unittest.mock import patch, MagicMock
from coach.prompt_template import _format_rag_cards, build_user_prompt
class TestFormatRagCards:
"""Tests for _format_rag_cards helper."""
def test_empty_list_returns_fallback(self):
assert _format_rag_cards([]) == "No RAG results available."
def test_none_returns_fallback(self):
# build_user_prompt passes rag_cards or [] so None shouldn't happen,
# but _format_rag_cards should handle it gracefully
assert "No RAG" in _format_rag_cards([])
def test_single_card_formatted(self):
cards = [{
"name": "Sol Ring",
"types": "Artifact",
"mana_value": 1,
"mana_cost": "{1}",
"distance": 0.123,
"text": "Tap: Add two colorless mana.",
}]
result = _format_rag_cards(cards)
assert "Sol Ring" in result
assert "{1}" in result
assert "MV:1" in result
assert "0.123" in result
assert "Tap: Add two colorless" in result
def test_max_15_cards(self):
cards = [{"name": f"Card {i}", "types": "Creature",
"mana_value": i, "mana_cost": f"{{{i}}}",
"distance": 0.1 * i, "text": ""} for i in range(20)]
result = _format_rag_cards(cards)
assert "Card 14" in result
assert "Card 15" not in result
def test_missing_fields_use_defaults(self):
cards = [{"name": "Mystery Card"}]
result = _format_rag_cards(cards)
assert "Mystery Card" in result
assert "MV:?" in result
class TestBuildUserPromptRagParam:
"""Tests that build_user_prompt accepts and renders rag_cards."""
@patch("coach.prompt_template._format_rag_cards")
def test_rag_cards_passed_to_formatter(self, mock_fmt):
mock_fmt.return_value = "MOCK_RAG_OUTPUT"
# We can't easily build a full DeckReport here without fixtures,
# so just verify the function signature accepts rag_cards.
# A full integration test would need the DeckReport factory.
assert callable(build_user_prompt)
import inspect
sig = inspect.signature(build_user_prompt)
assert "rag_cards" in sig.parameters
def test_rag_cards_default_is_none(self):
import inspect
sig = inspect.signature(build_user_prompt)
param = sig.parameters["rag_cards"]
assert param.default is None
class TestRagQueryIntegration:
"""Tests that coach_service calls query_cards (mocked)."""
@patch("services.rag_store.query_cards", return_value=[
{"name": "Arcane Signet", "types": "Artifact", "mana_value": 2,
"mana_cost": "{2}", "distance": 0.05, "text": "Tap: Add one mana."},
])
def test_query_cards_called_in_coaching(self, mock_qc):
"""Smoke test: import doesn't crash."""
from services.rag_store import query_cards
result = query_cards("Atraxa commander deck", n=15, colors=["W", "U", "B", "G"])
assert len(result) == 1
assert result[0]["name"] == "Arcane Signet"
mock_qc.assert_called_once()