From 500560d15fe7086fa7f4acb96fcb1e16392bd74f Mon Sep 17 00:00:00 2001 From: Gibeom Date: Fri, 14 Aug 2026 15:13:17 +0900 Subject: [PATCH] =?UTF-8?q?Feat(#64):=20RabbitMQ=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20evidenceCards=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REST 응답(SmishingAnalysisResponse.evidence)에는 이미 노출되던 증거 카드가 RabbitMQ AnalysisResultPayload에는 매핑되지 않아 Spring에 전달되지 않고 있었다. Spring PR #102(머지·배포됨)에서 합의한 대로 필드를 구현한다. - EvidenceCardDetail 스키마 추가 (category/title/description) - AnalysisResultPayload 최상위에 evidenceCards로 연결 - institutionMatch와 달리 규칙/URL/텍스트 트랙을 모두 아우르는 결과라 ruleAnalysis 하위가 아닌 최상위 (Spring과 합의된 위치) - result_factory.py에서 result.evidence(EvidenceItem 리스트)를 매핑, category는 enum .value로 변환 Spring이 이미 반영·배포된 상태(PR #102)라 배포 순서 조건 충족. --- app/infrastructure/rabbitmq/result_factory.py | 13 ++++++++ app/infrastructure/rabbitmq/schemas.py | 14 ++++++++ .../rabbitmq/test_result_factory.py | 33 +++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/app/infrastructure/rabbitmq/result_factory.py b/app/infrastructure/rabbitmq/result_factory.py index 0c10f7a..081c98b 100644 --- a/app/infrastructure/rabbitmq/result_factory.py +++ b/app/infrastructure/rabbitmq/result_factory.py @@ -11,6 +11,7 @@ AnalysisRequestedEvent, AnalysisResultEvent, AnalysisResultPayload, + EvidenceCardDetail, InstitutionMatchDetail, RawScores, RuleAnalysisDetail, @@ -108,11 +109,23 @@ def build_payload( ), urlAnalysis=(None if is_failed or not url else _url_detail(url)), ruleAnalysis=(None if is_failed or not rules else _rule_detail(rules)), + evidenceCards=_evidence_cards(result.evidence), failedTracks=list(failed_tracks), failureCode=("PIPELINE_FAILED" if is_failed else None), ) +def _evidence_cards(evidence) -> list[EvidenceCardDetail]: + return [ + EvidenceCardDetail( + category=item.category.value, + title=item.title, + description=item.description, + ) + for item in (evidence or []) + ] + + def _text_detail( text: dict, failed_tracks: tuple[str, ...], diff --git a/app/infrastructure/rabbitmq/schemas.py b/app/infrastructure/rabbitmq/schemas.py index 4cb6133..456a0d2 100644 --- a/app/infrastructure/rabbitmq/schemas.py +++ b/app/infrastructure/rabbitmq/schemas.py @@ -182,6 +182,16 @@ class RuleAnalysisDetail(BaseModel): institutionMatch: InstitutionMatchDetail | None = None +class EvidenceCardDetail(BaseModel): + """사용자 언어로 정리한 위험 근거 카드 (issue #64)""" + + model_config = ConfigDict(extra="forbid") + + category: str + title: str + description: str + + class AnalysisResultPayload(BaseModel): """분석 결과 이벤트에 포함되는 통합 분석 페이로드 스키마""" @@ -198,6 +208,10 @@ class AnalysisResultPayload(BaseModel): urlAnalysis: UrlAnalysisDetail | None = None ruleAnalysis: RuleAnalysisDetail | None = None + # 규칙/URL/텍스트(AI) 트랙을 모두 아우르는 결과라 특정 트랙 상세가 아닌 최상위에 둔다 + # (institutionMatch와 달리 ruleAnalysis 하위가 아님 - Spring PR #102와 합의된 위치). + evidenceCards: list[EvidenceCardDetail] = Field(default_factory=list) + failedTracks: list[str] = Field(default_factory=list) failureCode: str | None = None diff --git a/tests/infrastructure/rabbitmq/test_result_factory.py b/tests/infrastructure/rabbitmq/test_result_factory.py index aa58178..769f5f2 100644 --- a/tests/infrastructure/rabbitmq/test_result_factory.py +++ b/tests/infrastructure/rabbitmq/test_result_factory.py @@ -10,6 +10,8 @@ ) from app.analysis.schemas import ( ContributionBreakdown, + EvidenceCategory, + EvidenceItem, RiskGrade, SmishingAnalysisResponse, ) @@ -108,6 +110,17 @@ def _result( if status == "SUCCESS" else None ), + evidence=( + [ + EvidenceItem( + category=EvidenceCategory.INSTITUTION_IMPERSONATION, + title="기관 사칭", + description="국민은행을 언급했지만 공식 도메인이 아닙니다.", + ) + ] + if status == "SUCCESS" + else [] + ), ) @@ -176,6 +189,26 @@ def test_factory_maps_successful_execution( event.payload.ruleAnalysis.institutionMatch.textDomain == "kb-bank-security.xyz" ) + assert len(event.payload.evidenceCards) == 1 + assert event.payload.evidenceCards[0].category == "INSTITUTION_IMPERSONATION" + assert event.payload.evidenceCards[0].title == "기관 사칭" + assert event.payload.evidenceCards[0].description == ( + "국민은행을 언급했지만 공식 도메인이 아닙니다." + ) + + +def test_factory_maps_empty_evidence_cards_on_failure() -> None: + request = _request() + event = AnalysisResultEventFactory().create( + request=request, + execution=AnalysisExecution( + status=AnalysisExecutionStatus.FAILED, + result=_result(status="ERROR"), + failed_tracks=("PIPELINE",), + ), + ) + + assert event.payload.evidenceCards == [] def test_factory_maps_institution_match_not_checked() -> None: