fix: use prompt_override in gemini_client.decide() for playbook generation
Some checks failed
CI / test (pull_request) Has been cancelled
Some checks failed
CI / test (pull_request) Has been cancelled
decide() ignored market_data["prompt_override"], always building a generic trade-decision prompt. This caused pre_market_planner playbook generation to fail with JSONDecodeError on every market, falling back to defensive playbooks. Now prompt_override takes priority over both optimization and standard prompt building. Closes #143 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -184,3 +184,20 @@
|
|||||||
|
|
||||||
**효과:**
|
**효과:**
|
||||||
- 해외 시장 랭킹 스캔이 정상 동작하여 Smart Scanner가 후보 종목 탐지 가능
|
- 해외 시장 랭킹 스캔이 정상 동작하여 Smart Scanner가 후보 종목 탐지 가능
|
||||||
|
|
||||||
|
### Gemini prompt_override 미적용 버그 수정
|
||||||
|
|
||||||
|
**배경:**
|
||||||
|
- `run_overnight` 실행 시 모든 시장에서 Playbook 생성 실패 (`JSONDecodeError`)
|
||||||
|
- defensive playbook으로 폴백되어 모든 종목이 HOLD 처리
|
||||||
|
|
||||||
|
**근본 원인:**
|
||||||
|
- `pre_market_planner.py`가 `market_data["prompt_override"]`에 Playbook 전용 프롬프트를 넣어 `gemini.decide()` 호출
|
||||||
|
- `gemini_client.py`의 `decide()` 메서드가 `prompt_override` 키를 전혀 확인하지 않고 항상 일반 트레이드 결정 프롬프트 생성
|
||||||
|
- Gemini가 Playbook JSON 대신 일반 트레이드 결정을 반환하여 파싱 실패
|
||||||
|
|
||||||
|
**구현 결과:**
|
||||||
|
- `src/brain/gemini_client.py`: `decide()` 메서드에서 `prompt_override` 우선 사용 로직 추가
|
||||||
|
- `tests/test_brain.py`: 3개 테스트 추가 (override 전달, optimization 우회, 미지정 시 기존 동작 유지)
|
||||||
|
|
||||||
|
**이슈/PR:** #143
|
||||||
|
|||||||
@@ -410,8 +410,10 @@ class GeminiClient:
|
|||||||
cached=True,
|
cached=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build optimized prompt
|
# Build prompt (prompt_override takes priority for callers like pre_market_planner)
|
||||||
if self._enable_optimization:
|
if "prompt_override" in market_data:
|
||||||
|
prompt = market_data["prompt_override"]
|
||||||
|
elif self._enable_optimization:
|
||||||
prompt = self._optimizer.build_compressed_prompt(market_data)
|
prompt = self._optimizer.build_compressed_prompt(market_data)
|
||||||
else:
|
else:
|
||||||
prompt = await self.build_prompt(market_data, news_sentiment)
|
prompt = await self.build_prompt(market_data, news_sentiment)
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from src.brain.gemini_client import GeminiClient
|
from src.brain.gemini_client import GeminiClient
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -270,3 +274,97 @@ class TestBatchDecisionParsing:
|
|||||||
|
|
||||||
assert decisions["AAPL"].action == "HOLD"
|
assert decisions["AAPL"].action == "HOLD"
|
||||||
assert decisions["AAPL"].confidence == 0
|
assert decisions["AAPL"].confidence == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Prompt Override (used by pre_market_planner)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPromptOverride:
|
||||||
|
"""decide() must use prompt_override when present in market_data."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prompt_override_is_sent_to_gemini(self, settings):
|
||||||
|
"""When prompt_override is in market_data, it should be used as the prompt."""
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
|
||||||
|
custom_prompt = "You are a playbook generator. Return JSON with scenarios."
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.text = '{"action": "HOLD", "confidence": 50, "rationale": "test"}'
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
client._client.aio.models,
|
||||||
|
"generate_content",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_response,
|
||||||
|
) as mock_generate:
|
||||||
|
market_data = {
|
||||||
|
"stock_code": "PLANNER",
|
||||||
|
"current_price": 0,
|
||||||
|
"prompt_override": custom_prompt,
|
||||||
|
}
|
||||||
|
await client.decide(market_data)
|
||||||
|
|
||||||
|
# Verify the custom prompt was sent, not a built prompt
|
||||||
|
mock_generate.assert_called_once()
|
||||||
|
actual_prompt = mock_generate.call_args[1].get(
|
||||||
|
"contents", mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None
|
||||||
|
)
|
||||||
|
assert actual_prompt == custom_prompt
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prompt_override_skips_optimization(self, settings):
|
||||||
|
"""prompt_override should bypass prompt optimization."""
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
client._enable_optimization = True
|
||||||
|
|
||||||
|
custom_prompt = "Custom playbook prompt"
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.text = '{"action": "HOLD", "confidence": 50, "rationale": "ok"}'
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
client._client.aio.models,
|
||||||
|
"generate_content",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_response,
|
||||||
|
) as mock_generate:
|
||||||
|
market_data = {
|
||||||
|
"stock_code": "PLANNER",
|
||||||
|
"current_price": 0,
|
||||||
|
"prompt_override": custom_prompt,
|
||||||
|
}
|
||||||
|
await client.decide(market_data)
|
||||||
|
|
||||||
|
actual_prompt = mock_generate.call_args[1].get(
|
||||||
|
"contents", mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None
|
||||||
|
)
|
||||||
|
assert actual_prompt == custom_prompt
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_without_prompt_override_uses_build_prompt(self, settings):
|
||||||
|
"""Without prompt_override, decide() should use build_prompt as before."""
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.text = '{"action": "HOLD", "confidence": 50, "rationale": "ok"}'
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
client._client.aio.models,
|
||||||
|
"generate_content",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_response,
|
||||||
|
) as mock_generate:
|
||||||
|
market_data = {
|
||||||
|
"stock_code": "005930",
|
||||||
|
"current_price": 72000,
|
||||||
|
}
|
||||||
|
await client.decide(market_data)
|
||||||
|
|
||||||
|
actual_prompt = mock_generate.call_args[1].get(
|
||||||
|
"contents", mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None
|
||||||
|
)
|
||||||
|
# Should contain stock code from build_prompt, not be a custom override
|
||||||
|
assert "005930" in actual_prompt
|
||||||
|
|||||||
Reference in New Issue
Block a user