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: