fix(prediction): dark_suspicion Adapter 입력 매핑 교정 (run_outputs 0 rows silent) #95

병합
htlee fix/dark-suspicion-adapter-input 에서 develop 로 1 commits 를 머지했습니다 2026-04-20 16:06:13 +09:00
2개의 변경된 파일62개의 추가작업 그리고 56개의 파일을 삭제

파일 보기

@ -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 조회

파일 보기

@ -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',
]