From f7289606fc05ae82f04c4feb35deed7805b8fd1a Mon Sep 17 00:00:00 2001 From: agentson Date: Wed, 18 Feb 2026 02:02:13 +0900 Subject: [PATCH] fix: use prompt_override in gemini_client.decide() for playbook generation 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 --- docs/requirements-log.md | 17 +++++++ src/brain/gemini_client.py | 6 ++- tests/test_brain.py | 98 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/docs/requirements-log.md b/docs/requirements-log.md index 3a1c0df..0d684ce 100644 --- a/docs/requirements-log.md +++ b/docs/requirements-log.md @@ -184,3 +184,20 @@ **효과:** - 해외 시장 랭킹 스캔이 정상 동작하여 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 diff --git a/src/brain/gemini_client.py b/src/brain/gemini_client.py index be41bbf..2a11803 100644 --- a/src/brain/gemini_client.py +++ b/src/brain/gemini_client.py @@ -410,8 +410,10 @@ class GeminiClient: cached=True, ) - # Build optimized prompt - if self._enable_optimization: + # Build prompt (prompt_override takes priority for callers like pre_market_planner) + if "prompt_override" in market_data: + prompt = market_data["prompt_override"] + elif self._enable_optimization: prompt = self._optimizer.build_compressed_prompt(market_data) else: prompt = await self.build_prompt(market_data, news_sentiment) diff --git a/tests/test_brain.py b/tests/test_brain.py index d6e67cc..42eb49a 100644 --- a/tests/test_brain.py +++ b/tests/test_brain.py @@ -2,6 +2,10 @@ from __future__ import annotations +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + from src.brain.gemini_client import GeminiClient # --------------------------------------------------------------------------- @@ -270,3 +274,97 @@ class TestBatchDecisionParsing: assert decisions["AAPL"].action == "HOLD" 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