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

기존 DarkSuspicionModel.run() 이 ctx.inputs[*].features.gap_info.is_dark
로 대상을 필터했으나, 실제 AnalysisResult.features 는 gap_info 키를
포함하지 않고 다음으로 이미 flatten 저장됨:
  - dark_suspicion_score (0~100 int)
  - dark_tier ('CRITICAL'/'HIGH'/'WATCH'/'NONE')
  - dark_patterns (list[str])
  - gap_start_state / gap_start_sog / gap_start_lat / gap_start_lon

결과: PRIMARY 승격 + feature flag=1 적용해도 Adapter 가 한 선박도
평가하지 못해 detection_model_run_outputs 에 0 rows 적재 — E2E 검증
에서 evaluated_count=0 메트릭이 silent 조기 포착.

수정: Adapter 를 transshipment/risk/pair_trawl 과 동일한 관찰형으로 재작성.
  - row.is_dark 필터 후 기존 결과(score/tier/patterns) JSONB snapshot 기록
  - critical/high/watch/none_count + avg_score 메트릭 집계
  - input_ref: {mmsi, analyzed_at, gap_min}

운영 반영 후 1 사이클에서 3508 rows 적재 (141 CRITICAL / 467 HIGH /
963 WATCH / avg_score=27.78) 확인.

범위 밖 (후속 PR):
- compute_dark_suspicion 에 ACTIVE 버전 params 를 런타임 주입해 실제
  score/tier 재계산하는 scheduler 호출부 리팩토링. 현 상태는 params
  카탈로그 관찰까지만 (나머지 4 모델과 동일 수준).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-20 16:05:38 +09:00
부모 87916a55bf
커밋 20806ee80c
2개의 변경된 파일62개의 추가작업 그리고 56개의 파일을 삭제

파일 보기

@ -4,6 +4,9 @@
## [Unreleased] ## [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 조회 - **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 동치성 검증: scheduler.py (기존) analyze_dark_pattern() + compute_dark_suspicion() 실행
- params=None 호출하면 compute_dark_suspicion DEFAULT_PARAMS 사용해 AnalysisResult.features 다음 키로 flatten 저장:
기존 하드코딩 상수와 완전히 동일한 score/tier 낸다. - dark_suspicion_score (0~100 int)
- detection_model_versions.params DEFAULT 동일하면 · 경로 diff=0. - 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 모델을 호출하도록 Adapter 역할은 ** 결과를 `detection_model_run_outputs` 관찰 집계**.
전환 (현재는 ctx.shared 주입만, 기존 경로 영향 없음). 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 from __future__ import annotations
@ -21,7 +28,6 @@ from algorithms.dark_vessel import (
DARK_SUSPICION_DEFAULT_PARAMS, DARK_SUSPICION_DEFAULT_PARAMS,
compute_dark_suspicion, compute_dark_suspicion,
) )
from algorithms.location import classify_zone
from models_core.base import ( from models_core.base import (
BaseDetectionModel, BaseDetectionModel,
ModelContext, ModelContext,
@ -36,51 +42,42 @@ class DarkSuspicionModel(BaseDetectionModel):
def run(self, ctx: ModelContext) -> ModelResult: def run(self, ctx: ModelContext) -> ModelResult:
outputs_per_input: list[tuple[dict, dict]] = [] outputs_per_input: list[tuple[dict, dict]] = []
critical = 0 tiers = {'CRITICAL': 0, 'HIGH': 0, 'WATCH': 0, 'NONE': 0}
high = 0 score_sum = 0.0
watch = 0 evaluated = 0
for row in ctx.inputs or []:
if not row: for row in ctx.inputs or []:
continue if not row or not row.get('is_dark'):
mmsi = row.get('mmsi') continue
features = row.get('features') or {} features = row.get('features') or {}
gap_info = features.get('gap_info') if isinstance(features, dict) else None if not isinstance(features, dict):
if not gap_info or not gap_info.get('is_dark'): features = {}
continue
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(( 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, 'tier': tier,
'patterns': patterns, 'patterns': patterns,
}, },
)) ))
avg = score_sum / evaluated if evaluated else 0.0
return ModelResult( return ModelResult(
model_id=self.model_id, model_id=self.model_id,
version_id=self.version_id, version_id=self.version_id,
@ -88,12 +85,18 @@ class DarkSuspicionModel(BaseDetectionModel):
role=self.role, role=self.role,
outputs_per_input=outputs_per_input, outputs_per_input=outputs_per_input,
metrics={ metrics={
'evaluated_count': float(len(outputs_per_input)), 'evaluated_count': float(evaluated),
'critical_count': float(critical), 'avg_score': round(avg, 2),
'high_count': float(high), 'critical_count': float(tiers['CRITICAL']),
'watch_count': float(watch), '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',
]