From 37f33f5e29d688b8a0a653b74152990418c54447 Mon Sep 17 00:00:00 2001 From: Gibeom Date: Sun, 2 Aug 2026 13:40:27 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix=20:=20=EC=8A=A4=ED=82=A4=EB=A7=88=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/chat/prompts.py | 15 ++++++++++----- app/chat/schemas.py | 22 +++++++++++++++------- app/chat/service.py | 6 +----- tests/chat/test_prompts.py | 27 ++++++++++++++++----------- tests/chat/test_router.py | 10 ++++++---- tests/chat/test_schemas.py | 32 +++++++++++++++----------------- tests/chat/test_service.py | 8 ++++---- 7 files changed, 67 insertions(+), 53 deletions(-) diff --git a/app/chat/prompts.py b/app/chat/prompts.py index 4e8c8e8..67a57a5 100644 --- a/app/chat/prompts.py +++ b/app/chat/prompts.py @@ -22,11 +22,16 @@ 6. 답변은 한국어로, 간결하고 실행 가능한 안내 위주로 작성한다.""" -def build_system_prompt(analysis_context: AnalysisContext, indicators: list[str]) -> str: +def build_system_prompt(analysis_context: AnalysisContext) -> str: + indicators = analysis_context.indicators return SYSTEM_PROMPT_TEMPLATE.format( risk_score=analysis_context.riskScore, - risk_grade=analysis_context.riskGrade.value, - phishing_type=analysis_context.phishingType or "미분류", - summary=analysis_context.summary, - indicators=", ".join(indicators) if indicators else "없음", + risk_grade=analysis_context.riskLevel.value, + phishing_type=analysis_context.category, + summary=analysis_context.explanation, + indicators=( + ", ".join(f"{i.type}: {i.description}" for i in indicators) + if indicators + else "없음" + ), ) diff --git a/app/chat/schemas.py b/app/chat/schemas.py index 3ab9217..7335e11 100644 --- a/app/chat/schemas.py +++ b/app/chat/schemas.py @@ -10,20 +10,29 @@ class ChatRole(str, Enum): ASSISTANT = "assistant" +class Indicator(BaseModel): + """탐지 근거 하나. type/description 값 목록이 아직 확정되지 않아 자유 문자열로 수용""" + model_config = ConfigDict(extra="forbid") + + type: str + description: str + + class AnalysisContext(BaseModel): """Spring Boot가 /analyze 결과를 바탕으로 구성해 전달하는 분석 컨텍스트""" model_config = ConfigDict(extra="forbid") riskScore: int = Field(..., ge=0, le=100, description="최종 위험 점수 (0~100)") - riskGrade: RiskGrade = Field(..., description="위험 등급 (HIGH/MEDIUM/LOW)") - phishingType: str | None = Field(default=None, description="피싱 유형 (예: 기관 사칭형, 대출 사기형)") - summary: str = Field(..., description="분석 결과 요약 설명") + riskLevel: RiskGrade = Field(..., description="위험 등급 (HIGH/MEDIUM/LOW)") + category: str = Field(..., description="피싱 유형 분류 (예: FINANCIAL_INSTITUTION)") + explanation: str = Field(..., description="분석 결과 설명") + indicators: list[Indicator] = Field(default_factory=list, description="탐지 근거 목록") - @field_validator("summary") + @field_validator("explanation") @classmethod - def summary_must_not_be_blank(cls, value: str) -> str: + def explanation_must_not_be_blank(cls, value: str) -> str: if not value.strip(): - raise ValueError("summary must not be blank") + raise ValueError("explanation must not be blank") return value @@ -45,7 +54,6 @@ class ChatRequest(BaseModel): model_config = ConfigDict(extra="forbid") analysisContext: AnalysisContext - indicators: list[str] = Field(default_factory=list, description="탐지 근거 목록") messages: list[ChatMessage] = Field(..., min_length=1) diff --git a/app/chat/service.py b/app/chat/service.py index 53d485d..d4f0067 100644 --- a/app/chat/service.py +++ b/app/chat/service.py @@ -60,11 +60,7 @@ async def get_response(self, request: ChatRequest) -> ChatResponse: payload = { "system_instruction": { "parts": [ - { - "text": build_system_prompt( - request.analysisContext, request.indicators - ) - } + {"text": build_system_prompt(request.analysisContext)} ] }, "contents": _build_contents(request.messages), diff --git a/tests/chat/test_prompts.py b/tests/chat/test_prompts.py index 530a6b5..bed7004 100644 --- a/tests/chat/test_prompts.py +++ b/tests/chat/test_prompts.py @@ -5,29 +5,34 @@ def test_build_system_prompt_includes_context_fields(): context = AnalysisContext( riskScore=90, - riskGrade="HIGH", - phishingType="기관 사칭형", - summary="국민건강보험을 사칭한 스미싱 문자", + riskLevel="HIGH", + category="FINANCIAL_INSTITUTION", + explanation="국민건강보험을 사칭한 스미싱 문자", + indicators=[ + {"type": "MALICIOUS_URL", "description": "악성 이력이 확인된 URL"}, + {"type": "URGENCY_KEYWORD", "description": "즉시 확인 유도 문구"}, + ], ) - prompt = build_system_prompt(context, ["국민건강보험 언급", "즉시 확인 유도"]) + prompt = build_system_prompt(context) assert "90/100" in prompt assert "HIGH" in prompt assert "RiskGrade" not in prompt - assert "기관 사칭형" in prompt + assert "FINANCIAL_INSTITUTION" in prompt assert "국민건강보험을 사칭한 스미싱 문자" in prompt - assert "국민건강보험 언급, 즉시 확인 유도" in prompt + assert "MALICIOUS_URL: 악성 이력이 확인된 URL" in prompt + assert "URGENCY_KEYWORD: 즉시 확인 유도 문구" in prompt -def test_build_system_prompt_handles_missing_phishing_type_and_indicators(): +def test_build_system_prompt_handles_empty_indicators(): context = AnalysisContext( riskScore=10, - riskGrade="LOW", - summary="일상적인 대화", + riskLevel="LOW", + category="ETC", + explanation="일상적인 대화", ) - prompt = build_system_prompt(context, []) + prompt = build_system_prompt(context) - assert "미분류" in prompt assert "탐지 근거: 없음" in prompt diff --git a/tests/chat/test_router.py b/tests/chat/test_router.py index 876876b..a782d07 100644 --- a/tests/chat/test_router.py +++ b/tests/chat/test_router.py @@ -18,11 +18,13 @@ def _payload(**overrides) -> dict: payload = { "analysisContext": { "riskScore": 90, - "riskGrade": "HIGH", - "phishingType": "기관 사칭형", - "summary": "국민건강보험을 사칭한 스미싱 문자", + "riskLevel": "HIGH", + "category": "FINANCIAL_INSTITUTION", + "explanation": "국민건강보험을 사칭한 스미싱 문자", + "indicators": [ + {"type": "URGENCY_KEYWORD", "description": "즉시 확인 유도"} + ], }, - "indicators": ["즉시 확인 유도"], "messages": [{"role": "user", "content": "이거 진짜인가요?"}], } payload.update(overrides) diff --git a/tests/chat/test_schemas.py b/tests/chat/test_schemas.py index c643139..1958a7a 100644 --- a/tests/chat/test_schemas.py +++ b/tests/chat/test_schemas.py @@ -7,9 +7,12 @@ def _analysis_context(**overrides) -> AnalysisContext: defaults = { "riskScore": 90, - "riskGrade": "HIGH", - "phishingType": "기관 사칭형", - "summary": "국민건강보험을 사칭한 스미싱 문자", + "riskLevel": "HIGH", + "category": "FINANCIAL_INSTITUTION", + "explanation": "국민건강보험을 사칭한 스미싱 문자", + "indicators": [ + {"type": "MALICIOUS_URL", "description": "악성 이력이 확인된 URL입니다."} + ], } defaults.update(overrides) return AnalysisContext(**defaults) @@ -18,7 +21,6 @@ def _analysis_context(**overrides) -> AnalysisContext: def test_chat_request_accepts_valid_payload(): request = ChatRequest( analysisContext=_analysis_context(), - indicators=["국민건강보험 언급", "즉시 확인 유도"], messages=[{"role": "user", "content": "이거 진짜인가요?"}], ) @@ -26,18 +28,14 @@ def test_chat_request_accepts_valid_payload(): assert request.messages[0].role.value == "user" -def test_chat_request_defaults_indicators_to_empty_list(): - request = ChatRequest( - analysisContext=_analysis_context(), - messages=[{"role": "user", "content": "질문입니다"}], - ) - - assert request.indicators == [] +def test_analysis_context_defaults_indicators_to_empty_list(): + context = _analysis_context(indicators=[]) + assert context.indicators == [] def test_chat_request_rejects_empty_message_history(): with pytest.raises(ValidationError): - ChatRequest(analysisContext=_analysis_context(), indicators=[], messages=[]) + ChatRequest(analysisContext=_analysis_context(), messages=[]) def test_chat_message_rejects_invalid_role(): @@ -50,14 +48,14 @@ def test_chat_message_rejects_blank_content(): ChatMessage(role="user", content=" ") -def test_analysis_context_rejects_blank_summary(): +def test_analysis_context_rejects_blank_explanation(): with pytest.raises(ValidationError): - _analysis_context(summary=" ") + _analysis_context(explanation=" ") -def test_analysis_context_phishing_type_is_optional(): - context = _analysis_context(phishingType=None) - assert context.phishingType is None +def test_analysis_context_indicator_requires_type_and_description(): + with pytest.raises(ValidationError): + _analysis_context(indicators=[{"type": "MALICIOUS_URL"}]) def test_chat_request_rejects_unknown_fields(): diff --git a/tests/chat/test_service.py b/tests/chat/test_service.py index d5c9c3a..4c30154 100644 --- a/tests/chat/test_service.py +++ b/tests/chat/test_service.py @@ -10,11 +10,11 @@ def _request(messages=None) -> ChatRequest: return ChatRequest( analysisContext=AnalysisContext( riskScore=90, - riskGrade="HIGH", - phishingType="기관 사칭형", - summary="국민건강보험을 사칭한 스미싱 문자", + riskLevel="HIGH", + category="FINANCIAL_INSTITUTION", + explanation="국민건강보험을 사칭한 스미싱 문자", + indicators=[{"type": "URGENCY_KEYWORD", "description": "즉시 확인 유도"}], ), - indicators=["즉시 확인 유도"], messages=messages or [ChatMessage(role=ChatRole.USER, content="이거 진짜인가요?")], ) From 8c3a4a0faa8e4e066d5a0b6a4b7da142a06df86b Mon Sep 17 00:00:00 2001 From: Gibeom Date: Sun, 2 Aug 2026 13:49:03 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix=20:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/chat/test_schemas.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/chat/test_schemas.py b/tests/chat/test_schemas.py index 1958a7a..773ba92 100644 --- a/tests/chat/test_schemas.py +++ b/tests/chat/test_schemas.py @@ -29,7 +29,12 @@ def test_chat_request_accepts_valid_payload(): def test_analysis_context_defaults_indicators_to_empty_list(): - context = _analysis_context(indicators=[]) + context = AnalysisContext( + riskScore=90, + riskLevel="HIGH", + category="FINANCIAL_INSTITUTION", + explanation="국민건강보험을 사칭한 스미싱 문자", + ) assert context.indicators == [] @@ -56,6 +61,8 @@ def test_analysis_context_rejects_blank_explanation(): def test_analysis_context_indicator_requires_type_and_description(): with pytest.raises(ValidationError): _analysis_context(indicators=[{"type": "MALICIOUS_URL"}]) + with pytest.raises(ValidationError): + _analysis_context(indicators=[{"description": "악성 이력이 확인된 URL입니다."}]) def test_chat_request_rejects_unknown_fields():