diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 7963ade..cf69b53 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,9 @@ ## [Unreleased] +### 수정 +- **Phase 2 PoC `dark_suspicion` Adapter 입력 매핑 교정** — 기존 Adapter 가 `ctx.inputs[*].features.gap_info.is_dark` 로 대상을 필터했으나, 실제 AnalysisResult 의 `features` JSONB 는 `gap_info` 를 포함하지 않고 `dark_suspicion_score`/`dark_tier`/`dark_patterns`/`gap_start_*` 로 이미 flatten 저장됨 → Adapter 가 **한 선박도 평가하지 못해** `detection_model_run_outputs=0 rows` 로 나오던 silent 문제. E2E 검증에서 `evaluated_count=0` 메트릭으로 포착. 수정: Adapter 를 transshipment/risk/pair_trawl 과 동일한 "관찰형" 으로 재작성 — `row.is_dark` 필터 후 `features.dark_suspicion_score / dark_tier / dark_patterns` 를 그대로 JSONB snapshot 으로 기록, `critical/high/watch/none_count` + `avg_score` 메트릭 집계. 운영 반영 후 1 사이클에서 **3,508 rows 적재, 141 CRITICAL / 467 HIGH / 963 WATCH / avg_score=27.78** 확인. 런타임 params override (compute_dark_suspicion 에 ACTIVE 버전 params 주입) 는 scheduler 호출부 리팩토링 필요 — **후속 PR 로 이관** + ### 추가 - **Phase 3 MVP — Detection Model Registry 운영자 API (백엔드)** — `gc.mda.kcg.domain.ai` 패키지 신설, `ai-operations:detection-models` 권한 기반 8 엔드포인트: GET `/api/ai/detection-models` (카탈로그 목록, tier 정렬) / GET `/{modelId}` / GET `/{modelId}/dependencies` (DAG 선행) / GET `/{modelId}/versions` / GET `/{modelId}/versions/{versionId}` / POST `/{modelId}/versions` (DRAFT 생성, @Auditable `DETECTION_MODEL_VERSION_CREATE`) / POST `/versions/{versionId}/activate` (DRAFT → ACTIVE(role=PRIMARY·SHADOW·CHALLENGER), @Auditable `DETECTION_MODEL_VERSION_ACTIVATE`) / POST `/versions/{versionId}/archive` (ACTIVE/DRAFT → ARCHIVED, @Auditable `DETECTION_MODEL_VERSION_ARCHIVE`, idempotent). `DetectionModel`·`DetectionModelVersion` 엔티티(JSONB params 는 Hibernate `@JdbcTypeCode(SqlTypes.JSON)` 기반 `JsonNode` 매핑), `DetectionModelRepository`·`DetectionModelVersionRepository`, `DetectionModelService`(READ 전용)·`DetectionModelVersionService`(create/activate/archive, 전이 화이트리스트 + `uk_detection_model_primary` 중복 방지 409 응답), Request/Response record 4종. 로컬 `mvn spring-boot:run` 기동 성공 + admin 계정 쿠키 인증으로 8 엔드포인트 전수 smoke test 통과(5 모델 조회 / DRAFT 생성 → activate SHADOW → archive 전이 사이클 검증). **범위 밖 후속 PR**: promote-primary(SHADOW/CHALLENGER→PRIMARY) / enable 토글 / metrics · compare · runs 조회 diff --git a/prediction/models_core/registered/dark_suspicion_model.py b/prediction/models_core/registered/dark_suspicion_model.py index 362d9a2..07ecc6c 100644 --- a/prediction/models_core/registered/dark_suspicion_model.py +++ b/prediction/models_core/registered/dark_suspicion_model.py @@ -1,19 +1,26 @@ -"""dark_suspicion — 의도적 AIS OFF 의심 점수 모델 (Phase 2 PoC #1). +"""dark_suspicion — 의도적 AIS OFF 의심 점수 관찰 어댑터 (Phase 2 PoC #1). -구조: -- 기존 `algorithms.dark_vessel.compute_dark_suspicion` 을 그대로 사용 (BACK-COMPAT). -- 입력 단위: `ctx.inputs` 의 각 항목(AnalysisResult asdict) → (mmsi, gap_info). - prediction 기존 사이클이 이미 `analyze_dark_pattern` 을 돌려 AnalysisResult.features - 에 gap_info 와 dark_patterns 를 저장하므로, 이 모델은 **그 결과에 대해 score 를 재계산** - 하는 shadow 비교용이다. PRIMARY 경로는 아직 `scheduler.py` 의 기존 계산을 사용. +현재 운영 파이프라인의 데이터 흐름: -Phase 2 동치성 검증: -- params=None 로 호출하면 compute_dark_suspicion 이 DEFAULT_PARAMS 를 사용해 - 기존 하드코딩 상수와 완전히 동일한 score/tier 를 낸다. -- detection_model_versions.params 가 DEFAULT 와 동일하면 신·구 경로 diff=0. + scheduler.py (기존) → analyze_dark_pattern() + compute_dark_suspicion() 실행 → + AnalysisResult.features 에 다음 키로 flatten 저장: + - dark_suspicion_score (0~100 int) + - dark_tier ('CRITICAL'/'HIGH'/'WATCH'/'NONE') + - dark_patterns (list[str]) + - dark_history_7d / dark_history_24h + - gap_start_state / gap_start_sog / gap_start_lat / gap_start_lon -Phase 3 백엔드 API 연동 후 PRIMARY 로 승격하면 scheduler 도 이 모델을 호출하도록 -전환 (현재는 ctx.shared 주입만, 기존 경로 영향 없음). +Adapter 의 역할은 **이 결과를 `detection_model_run_outputs` 로 관찰 집계**. +scheduler 가 ACTIVE params 를 compute_dark_suspicion 호출 시점에 주입하도록 +연결하는 런타임 override 는 **후속 리팩토링 PR** 에서 수행한다 +(현 단계에서는 카탈로그 · 관찰 · 메트릭까지 확립). + +metrics: + · evaluated_count : is_dark=True 관찰 수 + · critical_count : dark_tier='CRITICAL' + · high_count : dark_tier='HIGH' + · watch_count : dark_tier='WATCH' + · avg_score : is_dark=True 중 dark_suspicion_score 평균 """ from __future__ import annotations @@ -21,7 +28,6 @@ from algorithms.dark_vessel import ( DARK_SUSPICION_DEFAULT_PARAMS, compute_dark_suspicion, ) -from algorithms.location import classify_zone from models_core.base import ( BaseDetectionModel, ModelContext, @@ -36,51 +42,42 @@ class DarkSuspicionModel(BaseDetectionModel): def run(self, ctx: ModelContext) -> ModelResult: outputs_per_input: list[tuple[dict, dict]] = [] - critical = 0 - high = 0 - watch = 0 - for row in ctx.inputs or []: - if not row: - continue - mmsi = row.get('mmsi') - features = row.get('features') or {} - gap_info = features.get('gap_info') if isinstance(features, dict) else None - if not gap_info or not gap_info.get('is_dark'): - continue + tiers = {'CRITICAL': 0, 'HIGH': 0, 'WATCH': 0, 'NONE': 0} + score_sum = 0.0 + evaluated = 0 + + for row in ctx.inputs or []: + if not row or not row.get('is_dark'): + continue + features = row.get('features') or {} + if not isinstance(features, dict): + features = {} + + score = int(features.get('dark_suspicion_score') or 0) + tier = features.get('dark_tier') or 'NONE' + patterns = features.get('dark_patterns') or [] + if not isinstance(patterns, list): + patterns = [] + + evaluated += 1 + score_sum += float(score) + if tier in tiers: + tiers[tier] += 1 - history = { - 'count_7d': row.get('dark_count_7d', 0), - 'count_24h': row.get('dark_count_24h', 0), - } - now_kst_hour = row.get('now_kst_hour', 0) - score, patterns, tier = compute_dark_suspicion( - gap_info=gap_info, - mmsi=mmsi, - is_permitted=bool(row.get('is_permitted', False)), - history=history, - now_kst_hour=int(now_kst_hour or 0), - classify_zone_fn=classify_zone, - ship_kind_code=row.get('ship_kind_code', '') or '', - nav_status=row.get('nav_status', '') or '', - heading=row.get('heading'), - last_cog=row.get('last_cog'), - params=self.params or None, - ) - if tier == 'CRITICAL': - critical += 1 - elif tier == 'HIGH': - high += 1 - elif tier == 'WATCH': - watch += 1 outputs_per_input.append(( - make_input_ref(mmsi, row.get('analyzed_at'), gap_min=gap_info.get('gap_min')), + make_input_ref( + row.get('mmsi'), + row.get('analyzed_at'), + gap_min=row.get('gap_duration_min'), + ), { - 'score': int(score), + 'score': score, 'tier': tier, 'patterns': patterns, }, )) + avg = score_sum / evaluated if evaluated else 0.0 return ModelResult( model_id=self.model_id, version_id=self.version_id, @@ -88,12 +85,18 @@ class DarkSuspicionModel(BaseDetectionModel): role=self.role, outputs_per_input=outputs_per_input, metrics={ - 'evaluated_count': float(len(outputs_per_input)), - 'critical_count': float(critical), - 'high_count': float(high), - 'watch_count': float(watch), + 'evaluated_count': float(evaluated), + 'avg_score': round(avg, 2), + 'critical_count': float(tiers['CRITICAL']), + 'high_count': float(tiers['HIGH']), + 'watch_count': float(tiers['WATCH']), + 'none_count': float(tiers['NONE']), }, ) -__all__ = ['DarkSuspicionModel', 'DARK_SUSPICION_DEFAULT_PARAMS'] +__all__ = [ + 'DarkSuspicionModel', + 'DARK_SUSPICION_DEFAULT_PARAMS', + 'compute_dark_suspicion', +]