Compare commits
9 커밋
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| b3b5a90a57 | |||
| 8f5152fc02 | |||
| 8c586c3384 | |||
| 735521966a | |||
| 2ceeb966d8 | |||
| d8fe1ef202 | |||
| f913953562 | |||
| 04d4b12c50 | |||
| 31a138e4ab |
@ -4,6 +4,24 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- **Phase 2 PoC 5 모델 마이그레이션 완료 (2 런타임 + 3 카탈로그)** — Phase 2 PoC 계획서의 5 알고리즘 전체를 detection_model 카탈로그로 등록하고 운영자 파라미터 튜닝 지점을 확보. 모드는 두 층위로 분리:
|
||||||
|
- **런타임 override 완성 (2 모델)** — `dark_suspicion` (tier 3, DARK_VESSEL, 19 가중치 + sog/반복/gap/tier 임계) · `gear_violation_g01_g06` (tier 4, GEAR, 6 G-code 점수 + signal cycling + gear drift + 허용 어구 매핑). 알고리즘 함수에 `params: dict | None = None` 인자 추가, `_merge_default_*_params` 깊이 병합으로 override 가 DEFAULT 를 변조하지 않는 불변성 보장. `params=None` 호출은 Phase 2 이전과 완전 동일 결과 (BACK-COMPAT). 운영자가 version 을 ACTIVE 로 승격하면 다음 사이클부터 실제 값 교체
|
||||||
|
- **카탈로그 + 관찰 (3 모델)** — `transshipment_5stage` (tier 4, TRANSSHIP, 5단계 필터 임계) · `risk_composite` (tier 3, META, 경량+파이프라인 가중치) · `pair_trawl_tier` (tier 4, GEAR, STRONG/PROBABLE/SUSPECT 임계). 내부 헬퍼들이 모듈 레벨 상수를 직접 참조하여 이번 단계에서는 DEFAULT_PARAMS 를 DB 에 노출 + Adapter 로 ctx.inputs 집계 관찰만 수행. 런타임 값 교체는 후속 리팩토링 PR 에서 헬퍼 params 전파를 완성하면 활성화
|
||||||
|
- **Adapter 5종** (`prediction/models_core/registered/*_model.py`) — `BaseDetectionModel` 상속, AnalysisResult 리스트에서 결과 집계 · tier/score 분포 메트릭 자동 기록
|
||||||
|
- **Seed SQL 5 + 통합 1** — 각 `prediction/models_core/seeds/v1_<model>.sql` + `v1_phase2_all.sql` 이 `\i` 로 5 모델 일괄 시드. BEGIN/COMMIT 제거로 호출자 트랜잭션 제어 가능
|
||||||
|
- **정적 동치성 검증 30 테스트** — 각 모델마다 Python DEFAULT 상수 ↔ 모듈 상수 ↔ seed SQL JSONB 3자 일치 검증. 5 모델 + Phase 1-2 기반 15 + dark 동치성 5 + Phase 2 8 신규 = 30/30 통과
|
||||||
|
- **운영 DB dry-run 통과** — 5 모델 개별 + 일괄 seed 모두 BEGIN/ROLLBACK 격리 검증, 반영 없이 SQL 정상 동작 확인
|
||||||
|
- **Phase 2 PoC #1 dark_suspicion 모델 마이그레이션** — `prediction/algorithms/dark_vessel.py` 의 `compute_dark_suspicion` 에 `params: dict | None = None` 인자 추가. `DARK_SUSPICION_DEFAULT_PARAMS` 상수(19개 가중치 + SOG 임계 + 반복 이력 임계 + tier 70/50/30)를 Python SSOT 로 추출하고, `_merge_default_params` 로 override 깊이 병합. `params=None` 시 Phase 2 이전과 **완전 동일한 결과** (BACK-COMPAT 보장). Adapter 클래스 `prediction/models_core/registered/dark_suspicion_model.py`(`BaseDetectionModel` 상속, AnalysisResult 리스트를 입력으로 gap_info 재평가, `evaluated/critical/high/watch_count` 메트릭 기록). Seed SQL `prediction/models_core/seeds/v1_dark_suspicion.sql` — `status=DRAFT role=NULL` 로 안전 seed (운영 영향 0, Phase 3 백엔드 API 승격 대기). 동치성 유닛테스트 5건 추가 (DEFAULT 형태 검증, None/빈dict 동치성, override 불변성, **seed SQL JSONB ↔ Python DEFAULT 1:1 정적 일치 검증**). 총 20/20 테스트 통과
|
||||||
|
- **Phase 1-2 Detection Model Registry 기반 인프라 (prediction)** — `prediction/models_core/` 패키지 신설. `BaseDetectionModel` 추상 계약 + `ModelContext` / `ModelResult` dataclass + `ModelRegistry`(ACTIVE 버전 전체 인스턴스화, DAG 순환 검출, topological 실행 플랜) + `DAGExecutor`(PRIMARY 실행→`ctx.shared` 주입→SHADOW/CHALLENGER persist-only 실행, 후행 모델은 PRIMARY 결과만 소비하는 오염 차단 불변식) + `params_loader`(V034 `detection_model_versions.params` JSONB 로드, 5분 TTL 캐시, `invalidate_cache()` 제공) + `feature_flag`(`PREDICTION_USE_MODEL_REGISTRY=0` 기본, `PREDICTION_CONCURRENT_SHADOWS=0` 기본). `scheduler.py` 10 단계에 feature flag 분기 추가해 기존 5분 사이클을 건드리지 않고 신 경로 공존. `db/partition_manager.py` 에 `detection_model_run_outputs` 월별 파티션 자동 생성/DROP 추가(system_config `partition.detection_model_run_outputs.*` 기반, 기본 retention_months=1, create_ahead_months=2). 유닛테스트 15 케이스(DAG 순환 검출, SHADOW 오염 차단, PRIMARY 실패→downstream skip, SHADOW 실패 격리, VARCHAR(64) 초과 거부) 전수 통과. 후속 Phase 2 에서 `models_core/registered/` 에 5 모델 PoC(`dark_suspicion`/`gear_violation_g01_g06`/`transshipment_5stage`/`risk_composite`/`pair_trawl_tier`) 추가 예정
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- **Snapshot 스크립트 silent-vs-fault 구분 + V030/V034 원시 관찰 섹션 추가** — `prediction/scripts/hourly-analysis-snapshot.sh`(cron 1h) + `diagnostic-snapshot.sh`(5min) 양쪽 공통. (1) `spoofing_score` 를 `gt0/gt03/gt05/gt07/avg/max` 세분화해 `spoof_hi=0` 이 "알고리즘 고장"인지 "threshold 미돌파"인지 한 눈에 구분. (2) V030 `gear_identity_collisions` 원시 테이블 섹션 신설 — `GEAR_IDENTITY_COLLISION` 이벤트만 관찰되던 상황에서 원시 테이블에 CRITICAL/OPEN 51건(coexistence 429, max_km 70km 페어) 잠복해 있음을 포착하도록 개선. (3) V034 `detection_model_*` 모니터링 섹션 — feature flag 활성화 후 모델·버전·role 별 적재·소요시간 즉시 가시화. (4) `stage_runner`(Phase 0-1) + `DAGExecutor` 로그 기반 STAGE TIMING 집계 — 소요시간 상위 10 + 실패 스테이지 식별. (5) `stats_hourly.by_category` vs raw `prediction_events.category` drift 감시 — `event_generator` silent drop 조기 탐지. redis-211 서버 반영 완료
|
||||||
|
- **Phase 1-2 silent error 선제 방어** — V034 스키마 `VARCHAR(64)` 컬럼 초과로 persist 가 주 사이클 밖에서 silent 실패하는 경로 3 건 선제 차단. `model_id` 는 `BaseDetectionModel.__init__` 에서 즉시 `ValueError`(클래스 정의 시점 검증). `metric_key` 는 경고 후 drop(다른 metric 는 계속 저장). `DAGExecutor` 가 `ctx.conn` 을 persist 에 재사용하도록 구조화해 maxconn=5 pool 고갈 방지 (`CONCURRENT_SHADOWS=1` 시 스레드풀과 병발해도 안전)
|
||||||
|
|
||||||
|
### 문서
|
||||||
|
- **2026-04-20 릴리즈 후속 정적 문서 최신화** — `architecture.md` 27→29 보호 경로 + 신규 라우트 2개, `sfr-traceability.md` V030→V034 · 51→56 테이블 · stage_runner · Phase 0-2/0-3 페이지 반영, `sfr-user-guide.md` 에 "불법 조업 이벤트" + "환적 의심 탐지" 사용자 가이드 섹션 신설, `system-flow-guide.md` V030~V034 매니페스트 미반영 경고 확장, `prediction-analysis.md` P1 권고 4건 중 3건 완료(✅) 표시
|
||||||
|
|
||||||
## [2026-04-20]
|
## [2026-04-20]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
@ -318,7 +318,7 @@ deps 변경 → useMapLayers → RAF → overlay.setProps() (React 리렌
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 라우팅 구조 (27 보호 경로 + login)
|
## 라우팅 구조 (29 보호 경로 + login)
|
||||||
|
|
||||||
`App.tsx`에서 `BrowserRouter` > `AuthProvider` > `Routes`로 구성된다.
|
`App.tsx`에서 `BrowserRouter` > `AuthProvider` > `Routes`로 구성된다.
|
||||||
|
|
||||||
@ -332,6 +332,8 @@ deps 변경 → useMapLayers → RAF → overlay.setProps() (React 리렌
|
|||||||
- `/dark-vessel` — 무등화 선박 탐지 (SFR-09)
|
- `/dark-vessel` — 무등화 선박 탐지 (SFR-09)
|
||||||
- `/gear-detection` — 어구 탐지 (SFR-10)
|
- `/gear-detection` — 어구 탐지 (SFR-10)
|
||||||
- `/gear-collision` — 어구 정체성 충돌 (SFR-10, V030 — 동일 어구 이름 × 복수 MMSI 공존 감지)
|
- `/gear-collision` — 어구 정체성 충돌 (SFR-10, V030 — 동일 어구 이름 × 복수 MMSI 공존 감지)
|
||||||
|
- `/illegal-fishing` — 불법 조업 이벤트 통합 대시보드 (SFR-09/10/11, V032 — GEAR_ILLEGAL+EEZ_INTRUSION+ZONE_DEPARTURE 3 카테고리)
|
||||||
|
- `/transshipment` — 환적 의심 전용 탐지 대시보드 (SFR-09, V033 — prediction 5단계 필터 결과)
|
||||||
- `/china-fishing` — 중국어선 탐지
|
- `/china-fishing` — 중국어선 탐지
|
||||||
- `/patrol-route` — 순찰경로 (SFR-07)
|
- `/patrol-route` — 순찰경로 (SFR-07)
|
||||||
- `/fleet-optimization` — 함대 최적화 (SFR-08)
|
- `/fleet-optimization` — 함대 최적화 (SFR-08)
|
||||||
|
|||||||
@ -180,10 +180,10 @@ def _stage(name: str, fn, *args, required=False, **kwargs):
|
|||||||
|
|
||||||
### P1 — 지금 해야 할 것 (운영 안정성)
|
### P1 — 지금 해야 할 것 (운영 안정성)
|
||||||
|
|
||||||
1. **사이클 스테이지 단위 에러 경계** — `_stage(name, fn, required=False)` 유틸로 9번 출력 5모듈을 쪼갤 것. `logger.exception` 으로 stacktrace 보존. `required=True` 를 `fetch_incremental` 같은 fatal 에만 적용 → 실패 시 조기 반환
|
1. ✅ **사이클 스테이지 단위 에러 경계** (2026-04-20 PR #83 완료) — `prediction/pipeline/stage_runner.py` 의 `run_stage(name, fn, required=False)` 유틸. 출력 6모듈(violation_classifier / event_generator / kpi_writer / stats_aggregate_hourly/daily / alert_dispatcher) 스테이지별 독립 실행 + 내부 6지점 `logger.warning → logger.exception` 전환. `upsert_results` required=True. **효과 검증**: 이 변경으로 V031 잠복 버그(`candidate_source` VARCHAR(30) 초과) 가 stacktrace 로 즉시 드러나 30분 내 hotfix 로 4시간 누락되던 `gear_group_parent_candidate_snapshots` 갱신 복원
|
||||||
2. **임계값 외부화** — `correlation_param_models` 패턴을 확장해 `detection_params` 테이블 신설 (algo_name, param_key, value, active_from, active_to). 배포 없이 해상도 튜닝 가능. 운영자 권한으로 접근 시 감사 로그
|
2. 🟡 **임계값 외부화** (2026-04-20 Phase 1-1 기반 머지, Phase 1-2 구현 대기) — V034 `detection_model_versions.params JSONB` + `correlation_param_models` 일반화. 기반 스키마 + 권한 완료, prediction Model Registry/DAG Executor 구현이 Phase 1-2 작업
|
||||||
3. **ILLEGAL_FISHING_PATTERN 전용 페이지** + **환적 전용 페이지** — 백엔드 API·DB 는 이미 존재. 프론트만 GearCollisionDetection 패턴으로 추가 (`PageContainer` + `DataTable` + `Badge intent`)
|
3. ✅ **ILLEGAL_FISHING_PATTERN 전용 페이지** (2026-04-20 PR #85 완료) + ✅ **환적 전용 페이지** (2026-04-20 PR #86 완료) — 둘 다 backend 변경 없이 프론트 전용. `/illegal-fishing` / `/transshipment` 메뉴 신설 + V032/V033 권한
|
||||||
4. **사이클 부분 원자성 명시** — DB 쓰기 경계 문서화 (어디까지가 한 트랜잭션인지). 최소한 [architecture.md](architecture.md) 또는 신설 `docs/prediction-transactions.md` 에 다이어그램
|
4. ⏸ **사이클 부분 원자성 명시** — DB 쓰기 경계 문서화. 향후 작업 (별도 `docs/prediction-transactions.md` 또는 architecture.md 확장 예정)
|
||||||
|
|
||||||
### P2 — 다음 (품질 확보)
|
### P2 — 다음 (품질 확보)
|
||||||
|
|
||||||
@ -248,3 +248,4 @@ def _stage(name: str, fn, *args, required=False, **kwargs):
|
|||||||
| 일자 | 내용 |
|
| 일자 | 내용 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 2026-04-17 | 초판 — opus 4.7 독립 리뷰. 구조/방향 중심 + 우선순위별 개선 제안 |
|
| 2026-04-17 | 초판 — opus 4.7 독립 리뷰. 구조/방향 중심 + 우선순위별 개선 제안 |
|
||||||
|
| 2026-04-20 | Phase 0-1/0-2/0-3 + Phase 1-1 V034 완료 반영. P1 권고 4건 중 3건 (사이클 에러 경계, 2개 UI 페이지) 완료 표시. 임계값 외부화(P1 #2) 는 기반 스키마 V034 머지 상태이며 Phase 1-2 Model Registry + DAG Executor 구현 대기 |
|
||||||
|
|||||||
@ -13,9 +13,9 @@
|
|||||||
| 레이어 | 기술 | 상태 |
|
| 레이어 | 기술 | 상태 |
|
||||||
|-------|------|------|
|
|-------|------|------|
|
||||||
| Frontend | React 19 + TypeScript 5.9 + Vite 8 + Tailwind CSS 4 + Zustand 5 + MapLibre GL 5 + deck.gl 9 + ECharts 6 + react-i18next | 운영 배포 (rocky-211 nginx) |
|
| Frontend | React 19 + TypeScript 5.9 + Vite 8 + Tailwind CSS 4 + Zustand 5 + MapLibre GL 5 + deck.gl 9 + ECharts 6 + react-i18next | 운영 배포 (rocky-211 nginx) |
|
||||||
| Backend | Spring Boot 3.5.7 + Java 21 + PostgreSQL 14.19 + Flyway V001~V030 + Spring Security + JWT + Caffeine + 트리 RBAC | 운영 배포 (rocky-211 :18080) |
|
| Backend | Spring Boot 3.5.7 + Java 21 + PostgreSQL 14.19 + Flyway V001~V034 + Spring Security + JWT + Caffeine + 트리 RBAC | 운영 (rocky-211 :18080, V034 재배포 대기) |
|
||||||
| Prediction | Python 3.11+ + FastAPI + APScheduler, 17 알고리즘 모듈 + 7단계 분류 파이프라인 + 5 출력/룰 모듈 | 운영 배포 (redis-211 :18092, 5분 주기) |
|
| Prediction | Python 3.11+ + FastAPI + APScheduler, 17 알고리즘 모듈 + 7단계 분류 파이프라인 + 5 출력/룰 모듈 + **stage_runner 사이클 에러 경계** (Phase 0-1) | 운영 배포 (redis-211 :18092, 5분 주기) |
|
||||||
| Database | PostgreSQL `kcgaidb` / 51 테이블 / schema `kcg` + snpdb(AIS 원천) | 운영 |
|
| Database | PostgreSQL `kcgaidb` / 51 → 56 테이블 (V034 반영 후 detection_model_* 5 + 뷰 1) / schema `kcg` + snpdb(AIS 원천) | 운영 (V034 반영 대기) |
|
||||||
| Design System | `/design-system.html` 쇼케이스 SSOT + `shared/constants/` 25개 카탈로그 + `shared/components/ui/` 9개 공통 컴포넌트 | SSOT 전영역 준수 (2026-04-17 PR #C 완료) |
|
| Design System | `/design-system.html` 쇼케이스 SSOT + `shared/constants/` 25개 카탈로그 + `shared/components/ui/` 9개 공통 컴포넌트 | SSOT 전영역 준수 (2026-04-17 PR #C 완료) |
|
||||||
| i18n | 10 네임스페이스 × ko/en, `common.json` 에 aria/error/dialog/message 54키 추가 | alert/confirm/aria-label 하드코딩 제거 완료 (2026-04-17 PR #B) |
|
| i18n | 10 네임스페이스 × ko/en, `common.json` 에 aria/error/dialog/message 54키 추가 | alert/confirm/aria-label 하드코딩 제거 완료 (2026-04-17 PR #B) |
|
||||||
|
|
||||||
@ -86,8 +86,8 @@ Frontend ← Backend /api/analysis/* + /api/events + /api/alerts + ... (65+ API)
|
|||||||
| SFR-06 | 단속 계획·경보 연계 | EnforcementPlan | ✅ /api/enforcement/plans | - |
|
| SFR-06 | 단속 계획·경보 연계 | EnforcementPlan | ✅ /api/enforcement/plans | - |
|
||||||
| SFR-07 | AI 경비함정 단일 함정 순찰·경로 | PatrolRoute | 🔲 Mock | - |
|
| SFR-07 | AI 경비함정 단일 함정 순찰·경로 | PatrolRoute | 🔲 Mock | - |
|
||||||
| SFR-08 | AI 경비함정 다함정 협력형 경로 | FleetOptimization | 🔲 Mock | - |
|
| SFR-08 | AI 경비함정 다함정 협력형 경로 | FleetOptimization | 🔲 Mock | - |
|
||||||
| SFR-09 | 불법 어선 패턴 탐지 (Dark Vessel) | DarkVesselDetection, TransferDetection | ✅ /api/analysis/* | ✅ Dark 11패턴 + Transship 5단계 |
|
| SFR-09 | 불법 어선 패턴 탐지 (Dark Vessel) | DarkVesselDetection, TransferDetection, **TransshipmentDetection(V033)** | ✅ /api/analysis/* | ✅ Dark 11패턴 + Transship 5단계 |
|
||||||
| SFR-10 | 불법 어망·어구 탐지 및 관리 | GearDetection, GearIdentification, GearCollisionDetection(V030) | ✅ /api/vessel-analysis/groups + /api/analysis/gear-detections + /api/analysis/gear-collisions | ✅ DAR-03 G-01~G-06 + pair tier + GEAR_IDENTITY_COLLISION(PR #73) |
|
| SFR-10 | 불법 어망·어구 탐지 및 관리 | GearDetection, GearIdentification, GearCollisionDetection(V030), **IllegalFishingPattern(V032)** | ✅ /api/vessel-analysis/groups + /api/analysis/gear-detections + /api/analysis/gear-collisions + /api/events?category= | ✅ DAR-03 G-01~G-06 + pair tier + GEAR_IDENTITY_COLLISION(PR #73) |
|
||||||
| SFR-11 | 단속·탐지 이력 관리 | EnforcementHistory, EventList | ✅ /api/events + /api/enforcement/records | ✅ prediction_events |
|
| SFR-11 | 단속·탐지 이력 관리 | EnforcementHistory, EventList | ✅ /api/events + /api/enforcement/records | ✅ prediction_events |
|
||||||
| SFR-12 | 모니터링 및 경보 현황판 | Dashboard, MonitoringDashboard, ChinaFishing | ✅ /api/stats + /api/alerts + /api/analysis/* | ✅ prediction_kpi_realtime + stats |
|
| SFR-12 | 모니터링 및 경보 현황판 | Dashboard, MonitoringDashboard, ChinaFishing | ✅ /api/stats + /api/alerts + /api/analysis/* | ✅ prediction_kpi_realtime + stats |
|
||||||
| SFR-13 | 통계·지표·성과 분석 | Statistics | ✅ /api/stats (daily/monthly/hourly) | ✅ prediction_stats_* |
|
| SFR-13 | 통계·지표·성과 분석 | Statistics | ✅ /api/stats (daily/monthly/hourly) | ✅ prediction_stats_* |
|
||||||
@ -228,7 +228,7 @@ Frontend ← Backend /api/analysis/* + /api/events + /api/alerts + ... (65+ API)
|
|||||||
|
|
||||||
**제안요청서 정의:** AIS 끊김·스푸핑·환적 등 의심 패턴 탐지.
|
**제안요청서 정의:** AIS 끊김·스푸핑·환적 등 의심 패턴 탐지.
|
||||||
|
|
||||||
**구현 화면:** `features/detection/DarkVesselDetection.tsx`, `features/vessel/TransferDetection.tsx`
|
**구현 화면:** `features/detection/DarkVesselDetection.tsx`, `features/detection/TransshipmentDetection.tsx`(V033/PR #86, 2026-04-20), `features/vessel/TransferDetection.tsx`
|
||||||
|
|
||||||
**Prediction 연동 ✅ 운영**:
|
**Prediction 연동 ✅ 운영**:
|
||||||
- **Dark Vessel**: 11패턴 P1~P11 기반 0~100점 연속 점수, 4 tier (CRITICAL≥70/HIGH≥50/WATCH≥30/NONE)
|
- **Dark Vessel**: 11패턴 P1~P11 기반 0~100점 연속 점수, 4 tier (CRITICAL≥70/HIGH≥50/WATCH≥30/NONE)
|
||||||
@ -239,6 +239,7 @@ Frontend ← Backend /api/analysis/* + /api/events + /api/alerts + ... (65+ API)
|
|||||||
**백엔드 연동 ✅**: `/api/analysis/vessels` + `/api/analysis/dark` + `/api/analysis/transship` + `/api/analysis/history`
|
**백엔드 연동 ✅**: `/api/analysis/vessels` + `/api/analysis/dark` + `/api/analysis/transship` + `/api/analysis/history`
|
||||||
- DarkDetailPanel: ScoreBreakdown + P1~P11 카탈로그
|
- DarkDetailPanel: ScoreBreakdown + P1~P11 카탈로그
|
||||||
- 2026-04-17 alertLevels 헬퍼(`ALERT_LEVEL_TIER_SCORE` 등) 적용
|
- 2026-04-17 alertLevels 헬퍼(`ALERT_LEVEL_TIER_SCORE` 등) 적용
|
||||||
|
- **TransshipmentDetection 전용 페이지 (V033, 2026-04-20)** — 5단계 필터 결과 목록·집계·상세 READ 전용 대시보드. `/api/analysis/transship` + `getTransshipSuspects` 재사용, features.transship_tier 로 tier 분류 (CRITICAL/HIGH/MEDIUM)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -258,6 +259,7 @@ Frontend ← Backend /api/analysis/* + /api/events + /api/alerts + ... (65+ API)
|
|||||||
- 페어 탐색 `find_pair_candidates` (bbox + 궤적 유사도 2차)
|
- 페어 탐색 `find_pair_candidates` (bbox + 궤적 유사도 2차)
|
||||||
- 한중어업협정 906척 NAME_EXACT + NAME_FUZZY 매칭 53%+
|
- 한중어업협정 906척 NAME_EXACT + NAME_FUZZY 매칭 53%+
|
||||||
- **GEAR_IDENTITY_COLLISION (V030/PR #73)** — 동일 어구 이름이 서로 다른 MMSI 로 동일 사이클 내 공존 감지 → `gear_identity_collisions` UPSERT(name, mmsi_lo, mmsi_hi), CRITICAL/HIGH/MEDIUM/LOW severity 분류. 이전 "교체(sequential)" 로 오해하던 케이스를 "어구 복제/스푸핑 증거" 로 재정의. SAVEPOINT + try/except 로 `gear_correlation_scores_pkey` 충돌 격리
|
- **GEAR_IDENTITY_COLLISION (V030/PR #73)** — 동일 어구 이름이 서로 다른 MMSI 로 동일 사이클 내 공존 감지 → `gear_identity_collisions` UPSERT(name, mmsi_lo, mmsi_hi), CRITICAL/HIGH/MEDIUM/LOW severity 분류. 이전 "교체(sequential)" 로 오해하던 케이스를 "어구 복제/스푸핑 증거" 로 재정의. SAVEPOINT + try/except 로 `gear_correlation_scores_pkey` 충돌 격리
|
||||||
|
- **IllegalFishingPattern 통합 대시보드 (V032/PR #85, 2026-04-20)** — `GEAR_ILLEGAL` + `EEZ_INTRUSION` + `ZONE_DEPARTURE` 3 카테고리 통합 뷰. 기존 `/api/events` 를 category 다중 병렬 호출 + occurredAt desc 머지로 backend 변경 없이 신설. KPI 5장 + 카테고리별 3장 + DataTable 7컬럼 + JSON features 상세 + EventList 네비게이션. 처리 액션은 EventList 경유 (READ 전용)
|
||||||
|
|
||||||
**백엔드 연동 ✅**:
|
**백엔드 연동 ✅**:
|
||||||
- `/api/vessel-analysis/groups` + `/groups/{key}/detail|correlations|candidates/{mmsi}/metrics|resolve` — 모선 워크플로우 (VesselAnalysisGroupService, 2026-04-17 PARENT_RESOLVE @Auditable 추가)
|
- `/api/vessel-analysis/groups` + `/groups/{key}/detail|correlations|candidates/{mmsi}/metrics|resolve` — 모선 워크플로우 (VesselAnalysisGroupService, 2026-04-17 PARENT_RESOLVE @Auditable 추가)
|
||||||
@ -413,8 +415,8 @@ Frontend ← Backend /api/analysis/* + /api/events + /api/alerts + ... (65+ API)
|
|||||||
| SFR-06 | `features/risk-assessment/EnforcementPlan.tsx`, `backend/.../enforcement/EnforcementController+Service.java` |
|
| SFR-06 | `features/risk-assessment/EnforcementPlan.tsx`, `backend/.../enforcement/EnforcementController+Service.java` |
|
||||||
| SFR-07 | `features/patrol/PatrolRoute.tsx` |
|
| SFR-07 | `features/patrol/PatrolRoute.tsx` |
|
||||||
| SFR-08 | `features/patrol/FleetOptimization.tsx` |
|
| SFR-08 | `features/patrol/FleetOptimization.tsx` |
|
||||||
| SFR-09 | `features/detection/DarkVesselDetection.tsx`, `features/detection/components/DarkDetailPanel.tsx`, `features/vessel/TransferDetection.tsx`, `prediction/algorithms/dark_vessel.py`, `spoofing.py`, `transship.py`, `risk.py` |
|
| SFR-09 | `features/detection/DarkVesselDetection.tsx`, `features/detection/components/DarkDetailPanel.tsx`, `features/detection/TransshipmentDetection.tsx`(V033), `features/vessel/TransferDetection.tsx`, `prediction/algorithms/dark_vessel.py`, `spoofing.py`, `transship.py`, `risk.py` |
|
||||||
| SFR-10 | `features/detection/GearDetection.tsx`, `GearIdentification.tsx`, `GearCollisionDetection.tsx`(V030), `features/detection/components/GearDetailPanel.tsx`, `GearReplayController.tsx`, `prediction/algorithms/pair_trawl.py`, `gear_violation.py`, `gear_identity.py`(V030), `gear_correlation.py`, `gear_parent_inference.py`, `vessel_type_mapping.py`, `backend/.../analysis/VesselAnalysisGroupService.java`, `GearCollisionController+Service.java`(V030) |
|
| SFR-10 | `features/detection/GearDetection.tsx`, `GearIdentification.tsx`, `GearCollisionDetection.tsx`(V030), `IllegalFishingPattern.tsx`(V032), `features/detection/components/GearDetailPanel.tsx`, `GearReplayController.tsx`, `prediction/algorithms/pair_trawl.py`, `gear_violation.py`, `gear_identity.py`(V030), `gear_correlation.py`, `gear_parent_inference.py`, `vessel_type_mapping.py`, `backend/.../analysis/VesselAnalysisGroupService.java`, `GearCollisionController+Service.java`(V030) |
|
||||||
| SFR-11 | `features/enforcement/EnforcementHistory.tsx`, `EventList.tsx`, `backend/.../event/EventController+Service.java`, `AlertService.java`, `enforcement/EnforcementService.java` |
|
| SFR-11 | `features/enforcement/EnforcementHistory.tsx`, `EventList.tsx`, `backend/.../event/EventController+Service.java`, `AlertService.java`, `enforcement/EnforcementService.java` |
|
||||||
| SFR-12 | `features/dashboard/Dashboard.tsx`, `features/monitoring/MonitoringDashboard.tsx`, `features/detection/ChinaFishing.tsx`, `features/detection/components/VesselMiniMap.tsx`, `VesselAnomalyPanel.tsx`, `backend/.../analysis/VesselAnalysisController+Service.java` |
|
| SFR-12 | `features/dashboard/Dashboard.tsx`, `features/monitoring/MonitoringDashboard.tsx`, `features/detection/ChinaFishing.tsx`, `features/detection/components/VesselMiniMap.tsx`, `VesselAnomalyPanel.tsx`, `backend/.../analysis/VesselAnalysisController+Service.java` |
|
||||||
| SFR-13 | `features/statistics/Statistics.tsx`, `ReportManagement.tsx`, `backend/.../stats/`, `admin/AdminStatsService.java` |
|
| SFR-13 | `features/statistics/Statistics.tsx`, `ReportManagement.tsx`, `backend/.../stats/`, `admin/AdminStatsService.java` |
|
||||||
|
|||||||
@ -481,6 +481,68 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 불법 조업 이벤트 (V032, 2026-04-20 추가)
|
||||||
|
|
||||||
|
**메뉴 위치:** 탐지/분석 > 불법 조업 이벤트
|
||||||
|
**URL:** `/illegal-fishing`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR, ANALYST, FIELD, VIEWER (READ 전용)
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
prediction 이 생산하는 **불법 조업 의심 이벤트 3 종을 한 화면에서 통합 조회**하는 READ 전용 대시보드입니다. 처리 액션(확인/상태변경/단속 등록)은 기존 `/event-list` 이벤트 목록에서 수행합니다.
|
||||||
|
|
||||||
|
**통합 대상 카테고리:**
|
||||||
|
- **어구 위반 (GEAR_ILLEGAL)** — G-01 수역-어구 불일치 · G-05 고정어구 drift · G-06 쌍끌이 공조
|
||||||
|
- **영해/접속수역 침범 (EEZ_INTRUSION)** — 영해 침범(CRITICAL) · 접속수역 + 고위험
|
||||||
|
- **특정수역 진입 (ZONE_DEPARTURE)** — ZONE_I~IV 진입 + 위험도 40점 이상
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 심각도별 KPI 5장 (전체 / CRITICAL / HIGH / MEDIUM / LOW)
|
||||||
|
- 카테고리별 건수 3장 + 설명
|
||||||
|
- 이벤트 목록 7컬럼 (발생시각 / 심각도 / 카테고리 / 제목 / MMSI / 수역 / 상태)
|
||||||
|
- 필터: 카테고리 (단일 또는 전체 3 카테고리 병합) / 심각도 / MMSI 검색
|
||||||
|
- 이벤트 상세 패널 — features JSON 원본 + `EventList 에서 열기` 네비게이션
|
||||||
|
|
||||||
|
**구현 완료 (2026-04-20 기준):**
|
||||||
|
- ✅ 기존 `/api/events?category=X` 를 3 카테고리에 대해 **병렬 호출** 후 occurredAt desc 머지 (backend 변경 없음)
|
||||||
|
- ✅ `frontend/src/services/illegalFishingPatternApi.ts` — `listIllegalFishingEvents` 함수 + byCategory/byLevel 집계
|
||||||
|
- ✅ V032 권한 트리 `detection:illegal-fishing` + 전 역할 READ 부여
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 직접 처리 액션 (ack / 단속 등록) 추가 — 현재는 EventList 네비게이션만
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 환적 의심 탐지 (V033, 2026-04-20 추가)
|
||||||
|
|
||||||
|
**메뉴 위치:** 탐지/분석 > 환적 의심 탐지
|
||||||
|
**URL:** `/transshipment`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR, ANALYST, FIELD, VIEWER (READ 전용)
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
prediction 의 **5단계 환적 필터 파이프라인** 결과를 전체 목록·집계·상세 수준으로 조회하는 READ 전용 대시보드입니다. 기존 `features/vessel/TransferDetection.tsx` 는 선박 상세 수준이고, 이 페이지는 **전체 환적 의심 선박 목록 운영 대시보드** 입니다.
|
||||||
|
|
||||||
|
**prediction 5단계 필터:**
|
||||||
|
1. 이종 쌍 (fishing ↔ carrier 매칭)
|
||||||
|
2. 감시영역 (관심 수역 내부)
|
||||||
|
3. RENDEZVOUS 90분 이상 접촉
|
||||||
|
4. 점수 50점 이상
|
||||||
|
5. 밀집 방폭 (군집 false positive 제거)
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- KPI 5장 — 전체 + Transship tier CRITICAL/HIGH/MEDIUM + 종합 위험도 CRITICAL
|
||||||
|
- 이벤트 목록 8컬럼 (분석시각 / MMSI / 상대 MMSI / 지속분 / Tier / 위험도 / 종합위험 / 수역)
|
||||||
|
- 필터: 조회기간(1/6/12/24/48h) / 위험도 / MMSI 검색
|
||||||
|
- 상세 패널 — 환적 점수 + 좌표 + features JSON 원본
|
||||||
|
|
||||||
|
**구현 완료 (2026-04-20 기준):**
|
||||||
|
- ✅ 기존 `/api/analysis/transship` + `getTransshipSuspects` 재사용 (backend 변경 없음)
|
||||||
|
- ✅ V033 권한 트리 `detection:transshipment` + 전 역할 READ 부여
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 VesselAnalysisController `/api/analysis/transship` 의 @RequirePermission 을 `detection:transshipment` 로 교체 (현재 `detection:dark-vessel` READ 로 가드 중, 운영자 역할은 양쪽 보유하여 실용 동작)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## SFR-11: 단속/탐지 이력 관리
|
## SFR-11: 단속/탐지 이력 관리
|
||||||
|
|
||||||
**메뉴 위치:** 단속/이력 > 단속/탐지 이력
|
**메뉴 위치:** 단속/이력 > 단속/탐지 이력
|
||||||
|
|||||||
@ -10,10 +10,15 @@ KCG AI Monitoring 시스템 워크플로우 플로우차트 뷰어 사용법.
|
|||||||
- 메인 SPA(`/`)와 완전 분리된 별도 React 앱
|
- 메인 SPA(`/`)와 완전 분리된 별도 React 앱
|
||||||
- 메뉴/링크 노출 없음 — 직접 URL 접근만
|
- 메뉴/링크 노출 없음 — 직접 URL 접근만
|
||||||
|
|
||||||
> ⚠️ **V030 미반영 경고**: 2026-04-17 V030 로 추가된 GEAR_IDENTITY_COLLISION 파이프라인 (
|
> ⚠️ **V030~V034 미반영 경고**: 2026-04-17 V030 (`algo.gear_identity_collision`,
|
||||||
> `algo.gear_identity_collision`, `storage.gear_identity_collisions`, `api.gear_collisions_*`,
|
> `storage.gear_identity_collisions`, `api.gear_collisions_*`, `ui.gear_collision`,
|
||||||
> `ui.gear_collision`, `decision.gear_collision_resolve`) 노드가 아직 manifest 에 등록되지
|
> `decision.gear_collision_resolve`) + 2026-04-20 V032 (`ui.illegal_fishing`) + V033
|
||||||
> 않았다. 다음 `/version` 릴리즈 시 매니페스트 동기화 필요.
|
> (`ui.transshipment_detection`) + **V034 Detection Model Registry**
|
||||||
|
> (`storage.detection_models`, `storage.detection_model_versions`,
|
||||||
|
> `storage.detection_model_run_outputs`, `storage.detection_model_metrics`,
|
||||||
|
> `infra.dag_executor`, `infra.shadow_runner`, `api.detection_models_*`,
|
||||||
|
> `ui.detection_model_management`) 노드들이 아직 manifest 에 등록되지 않았다.
|
||||||
|
> 다음 `/version` 릴리즈 시 매니페스트 일괄 동기화 필요.
|
||||||
|
|
||||||
## 접근 URL
|
## 접근 URL
|
||||||
|
|
||||||
|
|||||||
@ -211,6 +211,60 @@ def _is_in_kr_coverage(lat: Optional[float], lon: Optional[float]) -> bool:
|
|||||||
and _KR_COVERAGE_LON[0] <= lon <= _KR_COVERAGE_LON[1])
|
and _KR_COVERAGE_LON[0] <= lon <= _KR_COVERAGE_LON[1])
|
||||||
|
|
||||||
|
|
||||||
|
# compute_dark_suspicion 의 기본 파라미터 (`params=None` 시 사용).
|
||||||
|
# Phase 2 마이그레이션 — detection_model_versions.params JSONB 로 seed 되며,
|
||||||
|
# 운영자가 /ai/detection-models/{dark_suspicion}/versions 로 DRAFT → ACTIVE 시 교체.
|
||||||
|
# Python 상수를 단일 진실 공급원으로 삼고 registry seed 가 이 값을 그대로 복사한다.
|
||||||
|
DARK_SUSPICION_DEFAULT_PARAMS: dict = {
|
||||||
|
'sog_thresholds': {
|
||||||
|
'moving': 5.0, # P1 이동 중 OFF 판정 속도
|
||||||
|
'slow_moving': 2.0, # P1 서행 OFF 판정 속도
|
||||||
|
'underway_deliberate': 3.0, # P10 'under way' + 속도 시 의도성
|
||||||
|
},
|
||||||
|
'heading_cog_mismatch_deg': 60.0, # P11 heading vs COG diff 임계
|
||||||
|
'weights': {
|
||||||
|
'P1_moving_off': 25,
|
||||||
|
'P1_slow_moving_off': 15,
|
||||||
|
'P2_sensitive_zone': 25,
|
||||||
|
'P2_special_zone': 15,
|
||||||
|
'P3_repeat_high': 30,
|
||||||
|
'P3_repeat_low': 15,
|
||||||
|
'P3_recent_dark': 10,
|
||||||
|
'P4_distance_anomaly': 20,
|
||||||
|
'P5_daytime_fishing_off': 15,
|
||||||
|
'P6_teleport_before_gap': 15,
|
||||||
|
'P7_unpermitted': 10,
|
||||||
|
'P8_very_long_gap': 15,
|
||||||
|
'P8_long_gap': 10,
|
||||||
|
'P9_fishing_vessel_dark': 10,
|
||||||
|
'P9_cargo_natural_gap': -10,
|
||||||
|
'P10_underway_deliberate': 20,
|
||||||
|
'P10_anchored_natural': -15,
|
||||||
|
'P11_heading_cog_mismatch': 15,
|
||||||
|
'out_of_coverage': -50,
|
||||||
|
},
|
||||||
|
'repeat_thresholds': {'h7_high': 3, 'h7_low': 2, 'h24_recent': 1},
|
||||||
|
'gap_min_thresholds': {'very_long': 360, 'long': 180},
|
||||||
|
'p4_distance_multiplier': 2.0, # 예상 이동거리 대비 비정상 판정 배수
|
||||||
|
'p5_daytime_range': [6, 18], # [start, end) KST 시
|
||||||
|
'tier_thresholds': {'critical': 70, 'high': 50, 'watch': 30},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_default_params(override: Optional[dict]) -> dict:
|
||||||
|
"""override 딕셔너리의 값을 DEFAULT 에 깊이 병합 (unset 키는 기본값 사용)."""
|
||||||
|
if not override:
|
||||||
|
return DARK_SUSPICION_DEFAULT_PARAMS
|
||||||
|
merged = {k: (dict(v) if isinstance(v, dict) else v)
|
||||||
|
for k, v in DARK_SUSPICION_DEFAULT_PARAMS.items()}
|
||||||
|
for key, val in override.items():
|
||||||
|
if isinstance(val, dict) and isinstance(merged.get(key), dict):
|
||||||
|
merged[key] = {**merged[key], **val}
|
||||||
|
else:
|
||||||
|
merged[key] = val
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def compute_dark_suspicion(
|
def compute_dark_suspicion(
|
||||||
gap_info: dict,
|
gap_info: dict,
|
||||||
mmsi: str,
|
mmsi: str,
|
||||||
@ -222,6 +276,7 @@ def compute_dark_suspicion(
|
|||||||
nav_status: str = '',
|
nav_status: str = '',
|
||||||
heading: Optional[float] = None,
|
heading: Optional[float] = None,
|
||||||
last_cog: Optional[float] = None,
|
last_cog: Optional[float] = None,
|
||||||
|
params: Optional[dict] = None,
|
||||||
) -> tuple[int, list[str], str]:
|
) -> tuple[int, list[str], str]:
|
||||||
"""의도적 AIS OFF 의심 점수 산출.
|
"""의도적 AIS OFF 의심 점수 산출.
|
||||||
|
|
||||||
@ -236,6 +291,8 @@ def compute_dark_suspicion(
|
|||||||
nav_status: 항해 상태 텍스트 ("Under way using engine" 등)
|
nav_status: 항해 상태 텍스트 ("Under way using engine" 등)
|
||||||
heading: 선수 방향 (0~360, signal-batch API)
|
heading: 선수 방향 (0~360, signal-batch API)
|
||||||
last_cog: gap 직전 침로 (0~360)
|
last_cog: gap 직전 침로 (0~360)
|
||||||
|
params: detection_model_versions.params (None 이면 DEFAULT_PARAMS).
|
||||||
|
동일 입력 + params=None 은 Phase 2 이전과 완전 동일한 결과를 낸다.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(score, patterns, tier)
|
(score, patterns, tier)
|
||||||
@ -244,6 +301,14 @@ def compute_dark_suspicion(
|
|||||||
if not gap_info.get('is_dark'):
|
if not gap_info.get('is_dark'):
|
||||||
return 0, [], 'NONE'
|
return 0, [], 'NONE'
|
||||||
|
|
||||||
|
p = _merge_default_params(params)
|
||||||
|
w = p['weights']
|
||||||
|
sog = p['sog_thresholds']
|
||||||
|
rpt = p['repeat_thresholds']
|
||||||
|
gmt = p['gap_min_thresholds']
|
||||||
|
tier_thr = p['tier_thresholds']
|
||||||
|
day_start, day_end = p['p5_daytime_range']
|
||||||
|
|
||||||
score = 0
|
score = 0
|
||||||
patterns: list[str] = []
|
patterns: list[str] = []
|
||||||
|
|
||||||
@ -254,11 +319,11 @@ def compute_dark_suspicion(
|
|||||||
gap_min = gap_info.get('gap_min') or 0
|
gap_min = gap_info.get('gap_min') or 0
|
||||||
|
|
||||||
# P1: 이동 중 OFF
|
# P1: 이동 중 OFF
|
||||||
if gap_start_sog > 5.0:
|
if gap_start_sog > sog['moving']:
|
||||||
score += 25
|
score += w['P1_moving_off']
|
||||||
patterns.append('moving_at_off')
|
patterns.append('moving_at_off')
|
||||||
elif gap_start_sog > 2.0:
|
elif gap_start_sog > sog['slow_moving']:
|
||||||
score += 15
|
score += w['P1_slow_moving_off']
|
||||||
patterns.append('slow_moving_at_off')
|
patterns.append('slow_moving_at_off')
|
||||||
|
|
||||||
# P2: gap 시작 위치의 민감 수역
|
# P2: gap 시작 위치의 민감 수역
|
||||||
@ -267,10 +332,10 @@ def compute_dark_suspicion(
|
|||||||
zone_info = classify_zone_fn(gap_start_lat, gap_start_lon)
|
zone_info = classify_zone_fn(gap_start_lat, gap_start_lon)
|
||||||
zone = zone_info.get('zone', '')
|
zone = zone_info.get('zone', '')
|
||||||
if zone in ('TERRITORIAL_SEA', 'CONTIGUOUS_ZONE'):
|
if zone in ('TERRITORIAL_SEA', 'CONTIGUOUS_ZONE'):
|
||||||
score += 25
|
score += w['P2_sensitive_zone']
|
||||||
patterns.append('sensitive_zone')
|
patterns.append('sensitive_zone')
|
||||||
elif zone.startswith('ZONE_'):
|
elif zone.startswith('ZONE_'):
|
||||||
score += 15
|
score += w['P2_special_zone']
|
||||||
patterns.append('special_zone')
|
patterns.append('special_zone')
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@ -278,14 +343,14 @@ def compute_dark_suspicion(
|
|||||||
# P3: 반복 이력 (과거 7일)
|
# P3: 반복 이력 (과거 7일)
|
||||||
h7 = int(history.get('count_7d', 0) or 0)
|
h7 = int(history.get('count_7d', 0) or 0)
|
||||||
h24 = int(history.get('count_24h', 0) or 0)
|
h24 = int(history.get('count_24h', 0) or 0)
|
||||||
if h7 >= 3:
|
if h7 >= rpt['h7_high']:
|
||||||
score += 30
|
score += w['P3_repeat_high']
|
||||||
patterns.append('repeat_high')
|
patterns.append('repeat_high')
|
||||||
elif h7 >= 2:
|
elif h7 >= rpt['h7_low']:
|
||||||
score += 15
|
score += w['P3_repeat_low']
|
||||||
patterns.append('repeat_low')
|
patterns.append('repeat_low')
|
||||||
if h24 >= 1:
|
if h24 >= rpt['h24_recent']:
|
||||||
score += 10
|
score += w['P3_recent_dark']
|
||||||
patterns.append('recent_dark')
|
patterns.append('recent_dark')
|
||||||
|
|
||||||
# P4: gap 후 이동 거리 비정상
|
# P4: gap 후 이동 거리 비정상
|
||||||
@ -293,78 +358,73 @@ def compute_dark_suspicion(
|
|||||||
avg_sog_before = gap_info.get('avg_sog_before') or 0.0
|
avg_sog_before = gap_info.get('avg_sog_before') or 0.0
|
||||||
if gap_info.get('gap_resumed') and gap_min > 0:
|
if gap_info.get('gap_resumed') and gap_min > 0:
|
||||||
gap_hours = gap_min / 60.0
|
gap_hours = gap_min / 60.0
|
||||||
# 예상 이동 = avg_sog * gap_hours. 2배 초과면 비정상
|
|
||||||
expected = max(gap_hours * max(avg_sog_before, 1.0), 0.5)
|
expected = max(gap_hours * max(avg_sog_before, 1.0), 0.5)
|
||||||
if gap_distance_nm > expected * 2.0:
|
if gap_distance_nm > expected * p['p4_distance_multiplier']:
|
||||||
score += 20
|
score += w['P4_distance_anomaly']
|
||||||
patterns.append('distance_anomaly')
|
patterns.append('distance_anomaly')
|
||||||
|
|
||||||
# P5: 주간 조업 시간 OFF
|
# P5: 주간 조업 시간 OFF
|
||||||
if 6 <= now_kst_hour < 18 and gap_start_state == 'FISHING':
|
if day_start <= now_kst_hour < day_end and gap_start_state == 'FISHING':
|
||||||
score += 15
|
score += w['P5_daytime_fishing_off']
|
||||||
patterns.append('daytime_fishing_off')
|
patterns.append('daytime_fishing_off')
|
||||||
|
|
||||||
# P6: gap 직전 이상 행동
|
# P6: gap 직전 이상 행동
|
||||||
if gap_info.get('pre_gap_turn_or_teleport'):
|
if gap_info.get('pre_gap_turn_or_teleport'):
|
||||||
score += 15
|
score += w['P6_teleport_before_gap']
|
||||||
patterns.append('teleport_before_gap')
|
patterns.append('teleport_before_gap')
|
||||||
|
|
||||||
# P7: 무허가
|
# P7: 무허가
|
||||||
if not is_permitted:
|
if not is_permitted:
|
||||||
score += 10
|
score += w['P7_unpermitted']
|
||||||
patterns.append('unpermitted')
|
patterns.append('unpermitted')
|
||||||
|
|
||||||
# P8: gap 길이
|
# P8: gap 길이
|
||||||
if gap_min >= 360:
|
if gap_min >= gmt['very_long']:
|
||||||
score += 15
|
score += w['P8_very_long_gap']
|
||||||
patterns.append('very_long_gap')
|
patterns.append('very_long_gap')
|
||||||
elif gap_min >= 180:
|
elif gap_min >= gmt['long']:
|
||||||
score += 10
|
score += w['P8_long_gap']
|
||||||
patterns.append('long_gap')
|
patterns.append('long_gap')
|
||||||
|
|
||||||
# P9: 선종별 가중치 (signal-batch API 데이터)
|
# P9: 선종별 가중치
|
||||||
if ship_kind_code == '000020':
|
if ship_kind_code == '000020':
|
||||||
# 어선이면서 dark → 불법조업 의도 가능성
|
score += w['P9_fishing_vessel_dark']
|
||||||
score += 10
|
|
||||||
patterns.append('fishing_vessel_dark')
|
patterns.append('fishing_vessel_dark')
|
||||||
elif ship_kind_code == '000023':
|
elif ship_kind_code == '000023':
|
||||||
# 화물선은 원양 항해 중 자연 gap 빈번
|
score += w['P9_cargo_natural_gap']
|
||||||
score -= 10
|
|
||||||
patterns.append('cargo_natural_gap')
|
patterns.append('cargo_natural_gap')
|
||||||
|
|
||||||
# P10: 항해 상태 기반 의도성
|
# P10: 항해 상태 기반 의도성
|
||||||
if nav_status:
|
if nav_status:
|
||||||
status_lower = nav_status.lower()
|
status_lower = nav_status.lower()
|
||||||
if 'under way' in status_lower and gap_start_sog > 3.0:
|
if 'under way' in status_lower and gap_start_sog > sog['underway_deliberate']:
|
||||||
# 항행 중 갑자기 OFF → 의도적
|
score += w['P10_underway_deliberate']
|
||||||
score += 20
|
|
||||||
patterns.append('underway_deliberate_off')
|
patterns.append('underway_deliberate_off')
|
||||||
elif 'anchor' in status_lower or 'moored' in status_lower:
|
elif 'anchor' in status_lower or 'moored' in status_lower:
|
||||||
# 정박 중 gap → 자연스러움
|
score += w['P10_anchored_natural']
|
||||||
score -= 15
|
|
||||||
patterns.append('anchored_natural_gap')
|
patterns.append('anchored_natural_gap')
|
||||||
|
|
||||||
# P11: heading vs COG 불일치 (의도적 방향 전환)
|
# P11: heading vs COG 불일치
|
||||||
if heading is not None and last_cog is not None:
|
if heading is not None and last_cog is not None:
|
||||||
diff = abs(heading - last_cog) % 360
|
diff = abs(heading - last_cog) % 360
|
||||||
if diff > 180:
|
if diff > 180:
|
||||||
diff = 360 - diff
|
diff = 360 - diff
|
||||||
if diff > 60:
|
if diff > p['heading_cog_mismatch_deg']:
|
||||||
score += 15
|
score += w['P11_heading_cog_mismatch']
|
||||||
patterns.append('heading_cog_mismatch')
|
patterns.append('heading_cog_mismatch')
|
||||||
|
|
||||||
# 감점: gap 시작 위치가 한국 수신 커버리지 밖 → 자연 gap 가능성 높음
|
# 감점: gap 시작 위치가 한국 수신 커버리지 밖
|
||||||
if not _is_in_kr_coverage(gap_start_lat, gap_start_lon):
|
if not _is_in_kr_coverage(gap_start_lat, gap_start_lon):
|
||||||
score -= 50
|
score += w['out_of_coverage']
|
||||||
patterns.append('out_of_coverage')
|
patterns.append('out_of_coverage')
|
||||||
|
|
||||||
score = max(0, min(100, score))
|
score = max(0, min(100, score))
|
||||||
|
|
||||||
if score >= 70:
|
if score >= tier_thr['critical']:
|
||||||
tier = 'CRITICAL'
|
tier = 'CRITICAL'
|
||||||
elif score >= 50:
|
elif score >= tier_thr['high']:
|
||||||
tier = 'HIGH'
|
tier = 'HIGH'
|
||||||
elif score >= 30:
|
elif score >= tier_thr['watch']:
|
||||||
tier = 'WATCH'
|
tier = 'WATCH'
|
||||||
else:
|
else:
|
||||||
tier = 'NONE'
|
tier = 'NONE'
|
||||||
|
|||||||
@ -51,6 +51,67 @@ GEAR_DRIFT_THRESHOLD_NM = 0.270 # ≈ 500m (DAR-03 스펙, 조류 보정 전)
|
|||||||
FIXED_GEAR_TYPES = {'GN', 'TRAP', 'FYK', 'FPO', 'GNS', 'GND'}
|
FIXED_GEAR_TYPES = {'GN', 'TRAP', 'FYK', 'FPO', 'GNS', 'GND'}
|
||||||
|
|
||||||
|
|
||||||
|
# classify_gear_violations 의 Phase 2 파라미터 SSOT — DB seed 는 이 값을 그대로 복사
|
||||||
|
GEAR_VIOLATION_DEFAULT_PARAMS: dict = {
|
||||||
|
'scores': {
|
||||||
|
'G01_zone_violation': G01_SCORE,
|
||||||
|
'G02_closed_season': G02_SCORE,
|
||||||
|
'G03_unregistered_gear': G03_SCORE,
|
||||||
|
'G04_signal_cycling': G04_SCORE,
|
||||||
|
'G05_gear_drift': G05_SCORE,
|
||||||
|
'G06_pair_trawl': G06_SCORE,
|
||||||
|
},
|
||||||
|
'signal_cycling': {
|
||||||
|
'gap_min': SIGNAL_CYCLING_GAP_MIN,
|
||||||
|
'min_count': SIGNAL_CYCLING_MIN_COUNT,
|
||||||
|
},
|
||||||
|
'gear_drift_threshold_nm': GEAR_DRIFT_THRESHOLD_NM,
|
||||||
|
'fixed_gear_types': sorted(FIXED_GEAR_TYPES),
|
||||||
|
'fishery_code_allowed_gear': {
|
||||||
|
k: sorted(v) for k, v in FISHERY_CODE_ALLOWED_GEAR.items()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_default_gv_params(override: Optional[dict]) -> dict:
|
||||||
|
"""GEAR_VIOLATION_DEFAULT_PARAMS 에 override 깊이 병합. list/set 키는 override 가 치환."""
|
||||||
|
if not override:
|
||||||
|
return GEAR_VIOLATION_DEFAULT_PARAMS
|
||||||
|
merged = {
|
||||||
|
k: (dict(v) if isinstance(v, dict) else
|
||||||
|
(list(v) if isinstance(v, list) else v))
|
||||||
|
for k, v in GEAR_VIOLATION_DEFAULT_PARAMS.items()
|
||||||
|
}
|
||||||
|
for key, val in override.items():
|
||||||
|
if isinstance(val, dict) and isinstance(merged.get(key), dict):
|
||||||
|
merged[key] = {**merged[key], **val}
|
||||||
|
else:
|
||||||
|
merged[key] = val
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_signal_cycling_count(
|
||||||
|
gear_episodes: list[dict], threshold_min: int,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""_detect_signal_cycling 의 count-만 변형 (threshold 를 params 에서 받기 위함).
|
||||||
|
|
||||||
|
Returns: (cycling_count, total_episodes_evaluated)
|
||||||
|
"""
|
||||||
|
if not gear_episodes or len(gear_episodes) < 2:
|
||||||
|
return 0, len(gear_episodes or [])
|
||||||
|
sorted_eps = sorted(gear_episodes, key=lambda e: e['first_seen_at'])
|
||||||
|
cnt = 0
|
||||||
|
for i in range(1, len(sorted_eps)):
|
||||||
|
prev_end = sorted_eps[i - 1].get('last_seen_at')
|
||||||
|
curr_start = sorted_eps[i].get('first_seen_at')
|
||||||
|
if prev_end is None or curr_start is None:
|
||||||
|
continue
|
||||||
|
gap_min = (curr_start - prev_end).total_seconds() / 60.0
|
||||||
|
if 0 < gap_min <= threshold_min:
|
||||||
|
cnt += 1
|
||||||
|
return cnt, len(sorted_eps)
|
||||||
|
|
||||||
|
|
||||||
def _haversine_nm(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
def _haversine_nm(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||||
"""두 좌표 간 거리 (해리) — Haversine 공식."""
|
"""두 좌표 간 거리 (해리) — Haversine 공식."""
|
||||||
R = 3440.065
|
R = 3440.065
|
||||||
@ -196,6 +257,7 @@ def classify_gear_violations(
|
|||||||
permit_periods: Optional[list[tuple[datetime, datetime]]] = None,
|
permit_periods: Optional[list[tuple[datetime, datetime]]] = None,
|
||||||
registered_fishery_code: Optional[str] = None,
|
registered_fishery_code: Optional[str] = None,
|
||||||
observation_ts: Optional[datetime] = None,
|
observation_ts: Optional[datetime] = None,
|
||||||
|
params: Optional[dict] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""어구 위반 G코드 분류 메인 함수 (DAR-03).
|
"""어구 위반 G코드 분류 메인 함수 (DAR-03).
|
||||||
|
|
||||||
@ -229,7 +291,19 @@ def classify_gear_violations(
|
|||||||
}
|
}
|
||||||
|
|
||||||
판정 우선순위: ZONE_VIOLATION > PAIR_TRAWL > GEAR_MISMATCH > '' (정상)
|
판정 우선순위: ZONE_VIOLATION > PAIR_TRAWL > GEAR_MISMATCH > '' (정상)
|
||||||
|
|
||||||
|
params: detection_model_versions.params (None 이면 DEFAULT_PARAMS).
|
||||||
|
params=None 호출은 Phase 2 이전과 완전히 동일한 결과를 낸다.
|
||||||
"""
|
"""
|
||||||
|
p = _merge_default_gv_params(params)
|
||||||
|
scores = p['scores']
|
||||||
|
sc = p['signal_cycling']
|
||||||
|
fixed_gear_types = set(p['fixed_gear_types'])
|
||||||
|
# JSONB 는 list 로 저장되므로 set 으로 변환하여 _is_unregistered_gear 호출
|
||||||
|
allowed_gear_map = {
|
||||||
|
k: set(v) for k, v in p['fishery_code_allowed_gear'].items()
|
||||||
|
}
|
||||||
|
|
||||||
g_codes: list[str] = []
|
g_codes: list[str] = []
|
||||||
evidence: dict = {}
|
evidence: dict = {}
|
||||||
score = 0
|
score = 0
|
||||||
@ -241,7 +315,7 @@ def classify_gear_violations(
|
|||||||
allowed_gears: list[str] = zone_info.get('allowed_gears', [])
|
allowed_gears: list[str] = zone_info.get('allowed_gears', [])
|
||||||
if allowed_gears and gear_type not in allowed_gears:
|
if allowed_gears and gear_type not in allowed_gears:
|
||||||
g_codes.append('G-01')
|
g_codes.append('G-01')
|
||||||
score += G01_SCORE
|
score += scores['G01_zone_violation']
|
||||||
evidence['G-01'] = {
|
evidence['G-01'] = {
|
||||||
'zone': zone,
|
'zone': zone,
|
||||||
'gear': gear_type,
|
'gear': gear_type,
|
||||||
@ -262,7 +336,7 @@ def classify_gear_violations(
|
|||||||
in_closed = False
|
in_closed = False
|
||||||
if in_closed:
|
if in_closed:
|
||||||
g_codes.append('G-02')
|
g_codes.append('G-02')
|
||||||
score += G02_SCORE
|
score += scores['G02_closed_season']
|
||||||
evidence['G-02'] = {
|
evidence['G-02'] = {
|
||||||
'observed_at': observation_ts.isoformat() if observation_ts else None,
|
'observed_at': observation_ts.isoformat() if observation_ts else None,
|
||||||
'permit_periods': [
|
'permit_periods': [
|
||||||
@ -276,18 +350,25 @@ def classify_gear_violations(
|
|||||||
# ── G-03: 미등록/허가외 어구 ──────────────────────────────────
|
# ── G-03: 미등록/허가외 어구 ──────────────────────────────────
|
||||||
if registered_fishery_code:
|
if registered_fishery_code:
|
||||||
try:
|
try:
|
||||||
unregistered = _is_unregistered_gear(gear_type, registered_fishery_code)
|
# params 로 덮어쓴 매핑을 전달 (_is_unregistered_gear 는 기존 공개 시그니처 유지 — BACK-COMPAT)
|
||||||
|
allowed_set = allowed_gear_map.get(
|
||||||
|
registered_fishery_code.upper().strip()
|
||||||
|
)
|
||||||
|
if allowed_set is None:
|
||||||
|
unregistered = False
|
||||||
|
else:
|
||||||
|
unregistered = gear_type.upper().strip() not in allowed_set
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error('G-03 평가 실패 [mmsi=%s]: %s', mmsi, exc)
|
logger.error('G-03 평가 실패 [mmsi=%s]: %s', mmsi, exc)
|
||||||
unregistered = False
|
unregistered = False
|
||||||
if unregistered:
|
if unregistered:
|
||||||
g_codes.append('G-03')
|
g_codes.append('G-03')
|
||||||
score += G03_SCORE
|
score += scores['G03_unregistered_gear']
|
||||||
evidence['G-03'] = {
|
evidence['G-03'] = {
|
||||||
'detected_gear': gear_type,
|
'detected_gear': gear_type,
|
||||||
'registered_fishery_code': registered_fishery_code,
|
'registered_fishery_code': registered_fishery_code,
|
||||||
'allowed_gears': sorted(
|
'allowed_gears': sorted(
|
||||||
FISHERY_CODE_ALLOWED_GEAR.get(
|
allowed_gear_map.get(
|
||||||
registered_fishery_code.upper().strip(), set()
|
registered_fishery_code.upper().strip(), set()
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@ -300,19 +381,22 @@ def classify_gear_violations(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ── G-04: MMSI 조작 의심 (고정어구 신호 on/off 반복) ───────────
|
# ── G-04: MMSI 조작 의심 (고정어구 신호 on/off 반복) ───────────
|
||||||
if gear_episodes is not None and gear_type in FIXED_GEAR_TYPES:
|
if gear_episodes is not None and gear_type in fixed_gear_types:
|
||||||
try:
|
try:
|
||||||
is_cycling, cycling_count = _detect_signal_cycling(gear_episodes)
|
cycling_count, _ = _detect_signal_cycling_count(
|
||||||
|
gear_episodes, threshold_min=sc['gap_min'],
|
||||||
|
)
|
||||||
|
is_cycling = cycling_count >= sc['min_count']
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error('G-04 평가 실패 [mmsi=%s]: %s', mmsi, exc)
|
logger.error('G-04 평가 실패 [mmsi=%s]: %s', mmsi, exc)
|
||||||
is_cycling, cycling_count = False, 0
|
is_cycling, cycling_count = False, 0
|
||||||
|
|
||||||
if is_cycling:
|
if is_cycling:
|
||||||
g_codes.append('G-04')
|
g_codes.append('G-04')
|
||||||
score += G04_SCORE
|
score += scores['G04_signal_cycling']
|
||||||
evidence['G-04'] = {
|
evidence['G-04'] = {
|
||||||
'cycling_count': cycling_count,
|
'cycling_count': cycling_count,
|
||||||
'threshold_min': SIGNAL_CYCLING_GAP_MIN,
|
'threshold_min': sc['gap_min'],
|
||||||
}
|
}
|
||||||
if not judgment:
|
if not judgment:
|
||||||
judgment = 'GEAR_MISMATCH'
|
judgment = 'GEAR_MISMATCH'
|
||||||
@ -321,16 +405,18 @@ def classify_gear_violations(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ── G-05: 고정어구 인위적 이동 ────────────────────────────────
|
# ── G-05: 고정어구 인위적 이동 ────────────────────────────────
|
||||||
if gear_positions is not None and gear_type in FIXED_GEAR_TYPES:
|
if gear_positions is not None and gear_type in fixed_gear_types:
|
||||||
try:
|
try:
|
||||||
drift_result = _detect_gear_drift(gear_positions)
|
drift_result = _detect_gear_drift(
|
||||||
|
gear_positions, threshold_nm=p['gear_drift_threshold_nm'],
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error('G-05 평가 실패 [mmsi=%s]: %s', mmsi, exc)
|
logger.error('G-05 평가 실패 [mmsi=%s]: %s', mmsi, exc)
|
||||||
drift_result = {'drift_detected': False, 'drift_nm': 0.0, 'tidal_corrected': False}
|
drift_result = {'drift_detected': False, 'drift_nm': 0.0, 'tidal_corrected': False}
|
||||||
|
|
||||||
if drift_result['drift_detected']:
|
if drift_result['drift_detected']:
|
||||||
g_codes.append('G-05')
|
g_codes.append('G-05')
|
||||||
score += G05_SCORE
|
score += scores['G05_gear_drift']
|
||||||
evidence['G-05'] = drift_result
|
evidence['G-05'] = drift_result
|
||||||
if not judgment:
|
if not judgment:
|
||||||
judgment = 'GEAR_MISMATCH'
|
judgment = 'GEAR_MISMATCH'
|
||||||
@ -341,7 +427,7 @@ def classify_gear_violations(
|
|||||||
# ── G-06: 쌍끌이 공조 조업 ────────────────────────────────────
|
# ── G-06: 쌍끌이 공조 조업 ────────────────────────────────────
|
||||||
if pair_result and pair_result.get('pair_detected'):
|
if pair_result and pair_result.get('pair_detected'):
|
||||||
g_codes.append('G-06')
|
g_codes.append('G-06')
|
||||||
score += G06_SCORE
|
score += scores['G06_pair_trawl']
|
||||||
evidence['G-06'] = {
|
evidence['G-06'] = {
|
||||||
'sync_duration_min': pair_result.get('sync_duration_min'),
|
'sync_duration_min': pair_result.get('sync_duration_min'),
|
||||||
'mean_separation_nm': pair_result.get('mean_separation_nm'),
|
'mean_separation_nm': pair_result.get('mean_separation_nm'),
|
||||||
|
|||||||
@ -67,6 +67,42 @@ CANDIDATE_PROXIMITY_FACTOR = 2.0 # 후보 탐색 반경: PROXIMITY_NM × 2
|
|||||||
CANDIDATE_SOG_MIN = 1.5 # 후보 속력 하한 (완화)
|
CANDIDATE_SOG_MIN = 1.5 # 후보 속력 하한 (완화)
|
||||||
CANDIDATE_SOG_MAX = 5.0 # 후보 속력 상한 (완화)
|
CANDIDATE_SOG_MAX = 5.0 # 후보 속력 상한 (완화)
|
||||||
|
|
||||||
|
|
||||||
|
# Phase 2 PoC #5 — pair_trawl_tier 카탈로그 등록용 params snapshot.
|
||||||
|
# 내부 헬퍼들이 모듈 레벨 상수를 직접 참조하므로 이번 단계는 카탈로그·관찰만.
|
||||||
|
# 런타임 override 는 후속 리팩토링 PR 에서 활성화.
|
||||||
|
PAIR_TRAWL_DEFAULT_PARAMS: dict = {
|
||||||
|
'cycle_interval_min': CYCLE_INTERVAL_MIN,
|
||||||
|
'strong': {
|
||||||
|
'proximity_nm': PROXIMITY_NM,
|
||||||
|
'sog_delta_max': SOG_DELTA_MAX,
|
||||||
|
'cog_delta_max': COG_DELTA_MAX,
|
||||||
|
'sog_min': SOG_MIN,
|
||||||
|
'sog_max': SOG_MAX,
|
||||||
|
'min_sync_cycles': MIN_SYNC_CYCLES,
|
||||||
|
'simultaneous_gap_min': SIMULTANEOUS_GAP_MIN,
|
||||||
|
},
|
||||||
|
'probable': {
|
||||||
|
'min_block_cycles': PROBABLE_MIN_BLOCK_CYCLES,
|
||||||
|
'min_sync_ratio': PROBABLE_MIN_SYNC_RATIO,
|
||||||
|
'proximity_nm': PROBABLE_PROXIMITY_NM,
|
||||||
|
'sog_delta_max': PROBABLE_SOG_DELTA_MAX,
|
||||||
|
'cog_delta_max': PROBABLE_COG_DELTA_MAX,
|
||||||
|
'sog_min': PROBABLE_SOG_MIN,
|
||||||
|
'sog_max': PROBABLE_SOG_MAX,
|
||||||
|
},
|
||||||
|
'suspect': {
|
||||||
|
'min_block_cycles': SUSPECT_MIN_BLOCK_CYCLES,
|
||||||
|
'min_sync_ratio': SUSPECT_MIN_SYNC_RATIO,
|
||||||
|
},
|
||||||
|
'candidate_scan': {
|
||||||
|
'cell_size_deg': CELL_SIZE,
|
||||||
|
'proximity_factor': CANDIDATE_PROXIMITY_FACTOR,
|
||||||
|
'sog_min': CANDIDATE_SOG_MIN,
|
||||||
|
'sog_max': CANDIDATE_SOG_MAX,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
# 내부 헬퍼
|
# 내부 헬퍼
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -7,6 +7,59 @@ from algorithms.dark_vessel import detect_ais_gaps
|
|||||||
from algorithms.spoofing import detect_teleportation
|
from algorithms.spoofing import detect_teleportation
|
||||||
|
|
||||||
|
|
||||||
|
# Phase 2 PoC #4 — risk_composite 카탈로그 등록용 params snapshot.
|
||||||
|
# 현 런타임은 모듈 레벨 상수/inline 숫자를 직접 사용하며, 운영자 UI 에서
|
||||||
|
# 주요 가중치·임계를 조회·튜닝할 수 있도록 DB 에 노출한다. 런타임 override
|
||||||
|
# 는 후속 리팩토링 PR 에서 compute_lightweight_risk_score / compute_vessel_risk_score
|
||||||
|
# 에 params 인자 전파를 완성하면서 활성화된다.
|
||||||
|
RISK_COMPOSITE_DEFAULT_PARAMS: dict = {
|
||||||
|
'tier_thresholds': {'critical': 70, 'high': 50, 'medium': 30},
|
||||||
|
# 경량(파이프라인 미통과) 경로 — compute_lightweight_risk_score
|
||||||
|
'lightweight_weights': {
|
||||||
|
'territorial_sea': 40,
|
||||||
|
'contiguous_zone': 15,
|
||||||
|
'zone_unpermitted': 25,
|
||||||
|
'eez_lt12nm': 15,
|
||||||
|
'eez_lt24nm': 8,
|
||||||
|
'dark_suspicion_multiplier': 0.3,
|
||||||
|
'dark_gap_720_min': 25,
|
||||||
|
'dark_gap_180_min': 20,
|
||||||
|
'dark_gap_60_min': 15,
|
||||||
|
'dark_gap_30_min': 8,
|
||||||
|
'spoofing_gt07': 15,
|
||||||
|
'spoofing_gt05': 8,
|
||||||
|
'unpermitted_alone': 15,
|
||||||
|
'unpermitted_with_suspicion': 8,
|
||||||
|
'repeat_gte5': 10,
|
||||||
|
'repeat_gte2': 5,
|
||||||
|
},
|
||||||
|
# 파이프라인 통과(정밀) 경로 — compute_vessel_risk_score
|
||||||
|
'pipeline_weights': {
|
||||||
|
'territorial_sea': 40,
|
||||||
|
'contiguous_zone': 10,
|
||||||
|
'zone_unpermitted': 25,
|
||||||
|
'territorial_fishing': 20,
|
||||||
|
'fishing_segments_any': 5,
|
||||||
|
'trawl_uturn': 10,
|
||||||
|
'teleportation': 20,
|
||||||
|
'speed_jumps_ge3': 10,
|
||||||
|
'speed_jumps_ge1': 5,
|
||||||
|
'critical_gaps_ge60': 15,
|
||||||
|
'any_gaps': 5,
|
||||||
|
'unpermitted': 20,
|
||||||
|
},
|
||||||
|
'dark_suspicion_fallback_gap_min': {
|
||||||
|
'very_long_720': 720,
|
||||||
|
'long_180': 180,
|
||||||
|
'mid_60': 60,
|
||||||
|
'short_30': 30,
|
||||||
|
},
|
||||||
|
'spoofing_thresholds': {'high_0.7': 0.7, 'medium_0.5': 0.5},
|
||||||
|
'eez_proximity_nm': {'inner_12': 12, 'outer_24': 24},
|
||||||
|
'repeat_thresholds': {'h24_high': 5, 'h24_low': 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def compute_lightweight_risk_score(
|
def compute_lightweight_risk_score(
|
||||||
zone_info: dict,
|
zone_info: dict,
|
||||||
sog: float,
|
sog: float,
|
||||||
|
|||||||
@ -48,6 +48,27 @@ _EXCLUDED_SHIP_TY = frozenset({
|
|||||||
# shipTy 텍스트에 포함되면 CARRIER 로 승격 (부분일치, 대소문자 무시)
|
# shipTy 텍스트에 포함되면 CARRIER 로 승격 (부분일치, 대소문자 무시)
|
||||||
_CARRIER_HINTS = ('cargo', 'tanker', 'supply', 'carrier', 'reefer')
|
_CARRIER_HINTS = ('cargo', 'tanker', 'supply', 'carrier', 'reefer')
|
||||||
|
|
||||||
|
|
||||||
|
# Phase 2 PoC #3 — 카탈로그 등록용 파라미터 snapshot.
|
||||||
|
# 내부 헬퍼 함수들이 모듈 레벨 상수를 직접 쓰기 때문에 이번 단계에서는
|
||||||
|
# 런타임 override 없이 **카탈로그·관찰만 등록**한다.
|
||||||
|
# 운영자가 UI 에서 현재 값을 확인 가능하도록 DB 에 노출되며, 실제 값 교체는
|
||||||
|
# 후속 리팩토링 PR 에서 _is_proximity / _is_approach / _evict_expired 등
|
||||||
|
# 헬퍼에 params 인자를 전파하면서 활성화된다.
|
||||||
|
TRANSSHIPMENT_DEFAULT_PARAMS: dict = {
|
||||||
|
'sog_threshold_kn': SOG_THRESHOLD_KN,
|
||||||
|
'proximity_deg': PROXIMITY_DEG,
|
||||||
|
'approach_deg': APPROACH_DEG,
|
||||||
|
'rendezvous_min': RENDEZVOUS_MIN,
|
||||||
|
'pair_expiry_min': PAIR_EXPIRY_MIN,
|
||||||
|
'gap_tolerance_cycles': GAP_TOLERANCE_CYCLES,
|
||||||
|
'fishing_kinds': sorted(_FISHING_KINDS),
|
||||||
|
'carrier_kinds': sorted(_CARRIER_KINDS),
|
||||||
|
'excluded_ship_ty': sorted(_EXCLUDED_SHIP_TY),
|
||||||
|
'carrier_hints': list(_CARRIER_HINTS),
|
||||||
|
'min_score': 50, # detect_transshipment 의 `score >= 50만` 출력 필터
|
||||||
|
}
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
# 감시영역 로드
|
# 감시영역 로드
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -17,6 +17,7 @@ logger = logging.getLogger(__name__)
|
|||||||
SYSTEM_CONFIG = qualified_table('system_config')
|
SYSTEM_CONFIG = qualified_table('system_config')
|
||||||
GEAR_CORRELATION_RAW_METRICS = qualified_table('gear_correlation_raw_metrics')
|
GEAR_CORRELATION_RAW_METRICS = qualified_table('gear_correlation_raw_metrics')
|
||||||
GEAR_CORRELATION_SCORES = qualified_table('gear_correlation_scores')
|
GEAR_CORRELATION_SCORES = qualified_table('gear_correlation_scores')
|
||||||
|
DETECTION_MODEL_RUN_OUTPUTS = qualified_table('detection_model_run_outputs')
|
||||||
|
|
||||||
|
|
||||||
def _get_config_int(conn, key: str, default: int) -> int:
|
def _get_config_int(conn, key: str, default: int) -> int:
|
||||||
@ -99,6 +100,100 @@ def _drop_expired_partitions(conn, retention_days: int) -> int:
|
|||||||
return dropped
|
return dropped
|
||||||
|
|
||||||
|
|
||||||
|
def _create_future_monthly_detection_partitions(conn, months_ahead: int) -> int:
|
||||||
|
"""detection_model_run_outputs 미래 N개월 파티션 생성.
|
||||||
|
|
||||||
|
월별 RANGE 파티션 (cycle_started_at) — V034 에서 2026-04/05 가 Flyway 로 선생성.
|
||||||
|
이후는 이 함수가 매일 돌면서 `months_ahead` 만큼 미리 생성.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
생성된 파티션 수
|
||||||
|
"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
created = 0
|
||||||
|
try:
|
||||||
|
anchor = date.today().replace(day=1)
|
||||||
|
for i in range(months_ahead + 1):
|
||||||
|
# anchor 기준 +i 개월
|
||||||
|
y = anchor.year + (anchor.month - 1 + i) // 12
|
||||||
|
m = (anchor.month - 1 + i) % 12 + 1
|
||||||
|
start = date(y, m, 1)
|
||||||
|
ny = y + (1 if m == 12 else 0)
|
||||||
|
nm = 1 if m == 12 else m + 1
|
||||||
|
end = date(ny, nm, 1)
|
||||||
|
partition_name = f'detection_model_run_outputs_{y:04d}_{m:02d}'
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM pg_class c "
|
||||||
|
"JOIN pg_namespace n ON n.oid = c.relnamespace "
|
||||||
|
"WHERE c.relname = %s AND n.nspname = %s",
|
||||||
|
(partition_name, settings.KCGDB_SCHEMA),
|
||||||
|
)
|
||||||
|
if cur.fetchone() is None:
|
||||||
|
cur.execute(
|
||||||
|
f"CREATE TABLE IF NOT EXISTS {qualified_table(partition_name)} "
|
||||||
|
f"PARTITION OF {DETECTION_MODEL_RUN_OUTPUTS} "
|
||||||
|
f"FOR VALUES FROM ('{start.isoformat()}') TO ('{end.isoformat()}')"
|
||||||
|
)
|
||||||
|
created += 1
|
||||||
|
logger.info(
|
||||||
|
'created partition: %s.%s', settings.KCGDB_SCHEMA, partition_name,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
logger.error('failed to create detection_model_run_outputs partitions: %s', e)
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_expired_monthly_detection_partitions(conn, retention_months: int) -> int:
|
||||||
|
"""detection_model_run_outputs retention_months 초과 월 파티션 DROP.
|
||||||
|
|
||||||
|
SHADOW 원시 결과는 비교 분석 후 가치 낮음 — 기본 retention 은 1개월.
|
||||||
|
집계는 detection_model_metrics 에 보존되므로 원시 폐기해도 추적 가능.
|
||||||
|
"""
|
||||||
|
cutoff_anchor = date.today().replace(day=1)
|
||||||
|
# retention_months 만큼 과거로 이동
|
||||||
|
y = cutoff_anchor.year
|
||||||
|
m = cutoff_anchor.month - retention_months
|
||||||
|
while m <= 0:
|
||||||
|
m += 12
|
||||||
|
y -= 1
|
||||||
|
cutoff = date(y, m, 1)
|
||||||
|
|
||||||
|
cur = conn.cursor()
|
||||||
|
dropped = 0
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT c.relname FROM pg_class c "
|
||||||
|
"JOIN pg_namespace n ON n.oid = c.relnamespace "
|
||||||
|
"WHERE c.relname LIKE 'detection_model_run_outputs_%%' "
|
||||||
|
"AND n.nspname = %s AND c.relkind = 'r'",
|
||||||
|
(settings.KCGDB_SCHEMA,),
|
||||||
|
)
|
||||||
|
for (name,) in cur.fetchall():
|
||||||
|
tail = name[len('detection_model_run_outputs_'):]
|
||||||
|
try:
|
||||||
|
yy, mm = tail.split('_')
|
||||||
|
partition_start = date(int(yy), int(mm), 1)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
if partition_start < cutoff:
|
||||||
|
cur.execute(f'DROP TABLE IF EXISTS {qualified_table(name)}')
|
||||||
|
dropped += 1
|
||||||
|
logger.info(
|
||||||
|
'dropped expired partition: %s.%s', settings.KCGDB_SCHEMA, name,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
logger.error('failed to drop detection_model_run_outputs partitions: %s', e)
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
return dropped
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_stale_scores(conn, cleanup_days: int) -> int:
|
def _cleanup_stale_scores(conn, cleanup_days: int) -> int:
|
||||||
"""cleanup_days 이상 미관측 점수 레코드 삭제."""
|
"""cleanup_days 이상 미관측 점수 레코드 삭제."""
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@ -131,13 +226,25 @@ def maintain_partitions():
|
|||||||
retention = _get_config_int(conn, 'partition.raw_metrics.retention_days', 7)
|
retention = _get_config_int(conn, 'partition.raw_metrics.retention_days', 7)
|
||||||
ahead = _get_config_int(conn, 'partition.raw_metrics.create_ahead_days', 3)
|
ahead = _get_config_int(conn, 'partition.raw_metrics.create_ahead_days', 3)
|
||||||
cleanup_days = _get_config_int(conn, 'partition.scores.cleanup_days', 30)
|
cleanup_days = _get_config_int(conn, 'partition.scores.cleanup_days', 30)
|
||||||
|
det_months_ahead = _get_config_int(
|
||||||
|
conn, 'partition.detection_model_run_outputs.create_ahead_months', 2,
|
||||||
|
)
|
||||||
|
det_retention_months = _get_config_int(
|
||||||
|
conn, 'partition.detection_model_run_outputs.retention_months', 1,
|
||||||
|
)
|
||||||
|
|
||||||
created = _create_future_partitions(conn, ahead)
|
created = _create_future_partitions(conn, ahead)
|
||||||
dropped = _drop_expired_partitions(conn, retention)
|
dropped = _drop_expired_partitions(conn, retention)
|
||||||
cleaned = _cleanup_stale_scores(conn, cleanup_days)
|
cleaned = _cleanup_stale_scores(conn, cleanup_days)
|
||||||
|
|
||||||
|
det_created = _create_future_monthly_detection_partitions(conn, det_months_ahead)
|
||||||
|
det_dropped = _drop_expired_monthly_detection_partitions(conn, det_retention_months)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
'partition maintenance: %d created, %d dropped, %d stale scores cleaned '
|
'partition maintenance: %d created, %d dropped, %d stale scores cleaned '
|
||||||
'(retention=%dd, ahead=%dd, cleanup=%dd)',
|
'(retention=%dd, ahead=%dd, cleanup=%dd); '
|
||||||
|
'detection_model_run_outputs: %d created, %d dropped '
|
||||||
|
'(retention_months=%d, ahead_months=%d)',
|
||||||
created, dropped, cleaned, retention, ahead, cleanup_days,
|
created, dropped, cleaned, retention, ahead, cleanup_days,
|
||||||
|
det_created, det_dropped, det_retention_months, det_months_ahead,
|
||||||
)
|
)
|
||||||
|
|||||||
26
prediction/models_core/__init__.py
Normal file
26
prediction/models_core/__init__.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"""Detection Model Registry (Phase 1-2).
|
||||||
|
|
||||||
|
V034 detection_models / detection_model_versions 스키마 위에서
|
||||||
|
`ACTIVE` 상태 버전들을 인스턴스화하여 사이클 내에서 실행·비교하는 프레임.
|
||||||
|
|
||||||
|
공개 모듈:
|
||||||
|
- base : BaseDetectionModel, ModelContext, ModelResult
|
||||||
|
- params_loader: detection_model_versions.params JSONB 로드 + TTL 캐시
|
||||||
|
- registry : ACTIVE 버전 전체 로드 + DAG 검증
|
||||||
|
- executor : topo 순서 PRIMARY 실행 → ctx.shared 주입 → SHADOW/CHALLENGER 실행
|
||||||
|
- feature_flag : 신·구 경로 토글
|
||||||
|
|
||||||
|
핵심 불변식 (오염 차단):
|
||||||
|
- SHADOW/CHALLENGER 의 결과는 `ctx.shared[model_id]` 에 기록되지 않는다.
|
||||||
|
- 후행 PRIMARY 모델은 선행 PRIMARY 결과만 입력으로 받는다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .base import BaseDetectionModel, ModelContext, ModelResult
|
||||||
|
from .feature_flag import use_model_registry
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'BaseDetectionModel',
|
||||||
|
'ModelContext',
|
||||||
|
'ModelResult',
|
||||||
|
'use_model_registry',
|
||||||
|
]
|
||||||
150
prediction/models_core/base.py
Normal file
150
prediction/models_core/base.py
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
"""Detection Model 추상 계층.
|
||||||
|
|
||||||
|
prediction 모듈의 기존 함수형 알고리즘(`algorithms/*`) 을 그대로 두고,
|
||||||
|
Adapter 형태로 감싸서 "모델 단위 실행·버전·파라미터"를 표준화한다.
|
||||||
|
|
||||||
|
설계:
|
||||||
|
- `ModelContext` — 한 사이클의 공통 입력/공유 상태 (불변 전제)
|
||||||
|
- `ModelResult` — 한 모델·한 버전의 실행 결과 (입력별 output + 메트릭)
|
||||||
|
- `BaseDetectionModel` — 등록 가능한 최소 계약 (model_id / version / role / params / run)
|
||||||
|
|
||||||
|
불변식:
|
||||||
|
- SHADOW/CHALLENGER 는 `ctx.shared[model_id]` 에 기록되지 않음 (Executor 책임)
|
||||||
|
- `params` 는 DRAFT 로 수정, ACTIVE 는 immutable 스냅샷 (DB 제약과 같은 규약)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# role 상수 — DB CHECK 제약과 동일한 문자열
|
||||||
|
ROLE_PRIMARY = 'PRIMARY'
|
||||||
|
ROLE_SHADOW = 'SHADOW'
|
||||||
|
ROLE_CHALLENGER = 'CHALLENGER'
|
||||||
|
ALLOWED_ROLES = (ROLE_PRIMARY, ROLE_SHADOW, ROLE_CHALLENGER)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelContext:
|
||||||
|
"""한 사이클 공통 입력 + 모델 간 공유 상태.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
cycle_started_at: 사이클 시작 시각 (모든 모델·버전이 공유)
|
||||||
|
vessel_store: 인메모리 AIS 캐시 (Optional — 테스트 시 None 허용)
|
||||||
|
conn: kcgdb psycopg2 connection (Optional — 테스트 시 None 허용)
|
||||||
|
shared: 선행 모델 PRIMARY 결과 보관소. key=model_id, value=ModelResult
|
||||||
|
SHADOW/CHALLENGER 는 여기에 쓰지 않는다 (오염 차단).
|
||||||
|
inputs: 모델이 소비할 공통 입력 목록 (선박 row 등). 버전 간 공정 비교 보장.
|
||||||
|
extras: 필요시 모델별 보조 데이터 (feature flag, tunable 등)
|
||||||
|
"""
|
||||||
|
cycle_started_at: datetime
|
||||||
|
vessel_store: Any = None
|
||||||
|
conn: Any = None
|
||||||
|
shared: dict = field(default_factory=dict)
|
||||||
|
inputs: list = field(default_factory=list)
|
||||||
|
extras: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelResult:
|
||||||
|
"""한 모델·한 버전의 실행 결과.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
model_id: 모델 식별자
|
||||||
|
version_id: detection_model_versions.id
|
||||||
|
version_str: 'v1.0.0' 등 사람이 읽는 버전 문자열
|
||||||
|
role: PRIMARY / SHADOW / CHALLENGER
|
||||||
|
outputs_per_input: [(input_ref, output_dict), ...]
|
||||||
|
input_ref 는 비교용 키(예: {'mmsi': '412...', 'analyzed_at': ...})
|
||||||
|
output_dict 는 JSONB 저장 가능한 결과 snapshot
|
||||||
|
metrics: detection_model_metrics 로 기록될 집계 관측치
|
||||||
|
(key=metric_key, value=numeric)
|
||||||
|
duration_ms: 이 버전 단위 실행 소요
|
||||||
|
"""
|
||||||
|
model_id: str
|
||||||
|
version_id: int
|
||||||
|
version_str: str
|
||||||
|
role: str
|
||||||
|
outputs_per_input: list[tuple[dict, dict]] = field(default_factory=list)
|
||||||
|
metrics: dict[str, float] = field(default_factory=dict)
|
||||||
|
duration_ms: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class BaseDetectionModel(ABC):
|
||||||
|
"""탐지 모델 추상 베이스.
|
||||||
|
|
||||||
|
구현체는 `prediction/models_core/registered/` 하위에 두고
|
||||||
|
`ModelRegistry.discover_classes()` 가 자동 import 한다.
|
||||||
|
|
||||||
|
클래스 레벨 속성(model_id / depends_on) 은 **클래스 정의 시** 고정,
|
||||||
|
인스턴스 속성(version_id / version_str / role / params) 은
|
||||||
|
`ModelRegistry` 가 ACTIVE 버전 스냅샷을 읽어 주입한다.
|
||||||
|
|
||||||
|
한 `BaseDetectionModel` 서브클래스에 대해 DB 에 N 개 ACTIVE 버전이 있으면
|
||||||
|
Registry 는 **각 버전마다 별도 인스턴스**를 생성한다 (PRIMARY 1 + SHADOW/CHALLENGER N).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# --- 클래스 메타 (서브클래스가 override) ---
|
||||||
|
model_id: str = ''
|
||||||
|
depends_on: list[str] = []
|
||||||
|
|
||||||
|
# V034 스키마 컬럼 길이 상한 — 운영자 실수·장기 실행에서 silent 한 persist 실패를
|
||||||
|
# 방지하기 위해 클래스 정의 시점에 선제 검증한다.
|
||||||
|
_MODEL_ID_MAXLEN = 64
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
version_id: int,
|
||||||
|
version_str: str,
|
||||||
|
role: str,
|
||||||
|
params: dict,
|
||||||
|
) -> None:
|
||||||
|
if role not in ALLOWED_ROLES:
|
||||||
|
raise ValueError(f'invalid role: {role!r} (expected {ALLOWED_ROLES})')
|
||||||
|
if not self.model_id:
|
||||||
|
raise ValueError(
|
||||||
|
f'{type(self).__name__}.model_id is empty — override as class attribute'
|
||||||
|
)
|
||||||
|
if len(self.model_id) > self._MODEL_ID_MAXLEN:
|
||||||
|
raise ValueError(
|
||||||
|
f'{type(self).__name__}.model_id too long '
|
||||||
|
f'({len(self.model_id)} > {self._MODEL_ID_MAXLEN}): {self.model_id!r}'
|
||||||
|
)
|
||||||
|
self.version_id = version_id
|
||||||
|
self.version_str = version_str
|
||||||
|
self.role = role
|
||||||
|
self.params: dict = dict(params) if params else {}
|
||||||
|
|
||||||
|
# --- 서브클래스 구현 포인트 ---
|
||||||
|
@abstractmethod
|
||||||
|
def run(self, ctx: ModelContext) -> ModelResult:
|
||||||
|
"""한 사이클에 대해 모델을 실행.
|
||||||
|
|
||||||
|
반환값의 `outputs_per_input` 은 입력 단위 비교가 가능하도록
|
||||||
|
**같은 input_ref 스키마를 같은 model_id 내에서 유지**해야 한다.
|
||||||
|
(PRIMARY 와 SHADOW 의 input_ref 가 일치해야 diff JOIN 이 가능.)
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
# --- 편의 ---
|
||||||
|
def label(self) -> str:
|
||||||
|
return f'{self.model_id}@{self.role}[{self.version_str}]'
|
||||||
|
|
||||||
|
def __repr__(self) -> str: # pragma: no cover
|
||||||
|
return f'<{type(self).__name__} {self.label()} version_id={self.version_id}>'
|
||||||
|
|
||||||
|
|
||||||
|
def make_input_ref(mmsi: str, analyzed_at: Optional[datetime] = None, **extra) -> dict:
|
||||||
|
"""관용 input_ref 생성기. PRIMARY/SHADOW 가 같은 포맷을 쓰도록 강제하는 도우미."""
|
||||||
|
ref: dict[str, Any] = {'mmsi': str(mmsi)}
|
||||||
|
if analyzed_at is not None:
|
||||||
|
ref['analyzed_at'] = analyzed_at.isoformat() if isinstance(analyzed_at, datetime) else analyzed_at
|
||||||
|
for k, v in extra.items():
|
||||||
|
ref[k] = v
|
||||||
|
return ref
|
||||||
287
prediction/models_core/executor.py
Normal file
287
prediction/models_core/executor.py
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
"""DAGExecutor — ExecutionPlan 을 실제로 돌리고 DB 에 결과/메트릭을 기록한다.
|
||||||
|
|
||||||
|
불변식 (테스트로도 검증):
|
||||||
|
1. PRIMARY 실행 결과만 `ctx.shared[model_id]` 에 주입 (후행 모델의 입력 소스).
|
||||||
|
2. SHADOW/CHALLENGER 결과는 `detection_model_run_outputs` 에 저장만, shared 에 **절대 주입 금지**.
|
||||||
|
3. PRIMARY 가 실패하면 후행 모델 실행 skip (upstream 결과 없음).
|
||||||
|
SHADOW/CHALLENGER 실패는 그 버전만 skip, 다른 버전·후행 모델에 영향 없음.
|
||||||
|
|
||||||
|
DB persist:
|
||||||
|
- detection_model_run_outputs (PARTITION BY cycle_started_at): execute_values 배치 INSERT
|
||||||
|
- detection_model_metrics: 집계 메트릭
|
||||||
|
|
||||||
|
참고: docs/prediction-analysis.md §7, plans/vast-tinkering-knuth.md Phase 1-2
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pipeline.stage_runner import run_stage
|
||||||
|
|
||||||
|
from .base import BaseDetectionModel, ModelContext, ModelResult, ROLE_PRIMARY
|
||||||
|
from .feature_flag import concurrent_shadows
|
||||||
|
from .registry import ExecutionPlan
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# V034 스키마 VARCHAR(64) — 초과하면 persist 가 silent 하게 실패하므로 선제 절단·경고
|
||||||
|
_METRIC_KEY_MAXLEN = 64
|
||||||
|
|
||||||
|
|
||||||
|
class DAGExecutor:
|
||||||
|
"""ExecutionPlan 을 실행하고 DB persist 를 담당.
|
||||||
|
|
||||||
|
persist 는 ctx.conn 을 재사용한다 (pool 중복 획득 방지).
|
||||||
|
ctx.conn 이 None 이면 기본 persist 함수들이 자체적으로 get_conn() 호출.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
plan: ExecutionPlan,
|
||||||
|
*,
|
||||||
|
persist_fn=None,
|
||||||
|
persist_metrics_fn=None,
|
||||||
|
) -> None:
|
||||||
|
self.plan = plan
|
||||||
|
# 테스트에서 DB 없이 돌리기 위해 persist 훅을 주입 가능하게 만든다.
|
||||||
|
self._persist_fn = persist_fn or _persist_run_outputs
|
||||||
|
self._persist_metrics_fn = persist_metrics_fn or _persist_metrics
|
||||||
|
self._ctx_conn = None # run() 진입 시 셋업
|
||||||
|
|
||||||
|
def run(self, ctx: ModelContext) -> dict:
|
||||||
|
"""전체 Plan 실행.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{'executed': int, 'failed': int, 'shadow_ran': int, 'shadow_failed': int}
|
||||||
|
"""
|
||||||
|
# ctx.conn 이 있으면 persist 도 이 conn 을 재사용하도록 보관한다.
|
||||||
|
# (maxconn=5 pool 고갈 방지 — persist 마다 별도 get_conn() 획득 금지)
|
||||||
|
self._ctx_conn = getattr(ctx, 'conn', None)
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
'executed': 0,
|
||||||
|
'failed': 0,
|
||||||
|
'skipped_missing_deps': 0,
|
||||||
|
'shadow_ran': 0,
|
||||||
|
'shadow_failed': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for model_id in self.plan.topo_order:
|
||||||
|
primary = self.plan.primaries.get(model_id)
|
||||||
|
shadows = list(self.plan.shadows.get(model_id, []))
|
||||||
|
|
||||||
|
if primary is None:
|
||||||
|
# PRIMARY 없이 SHADOW 만 있는 모델은 실행 불가 (비교 기준이 없음)
|
||||||
|
if shadows:
|
||||||
|
logger.warning(
|
||||||
|
'model %s has %d SHADOW/CHALLENGER but no PRIMARY — skipping',
|
||||||
|
model_id, len(shadows),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# upstream PRIMARY 결과가 모두 있는지 확인
|
||||||
|
missing = [
|
||||||
|
dep for dep in self.plan.edges.get(model_id, ())
|
||||||
|
if dep not in ctx.shared
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
summary['skipped_missing_deps'] += 1
|
||||||
|
logger.warning(
|
||||||
|
'skip %s — upstream PRIMARY missing: %s',
|
||||||
|
primary.label(), missing,
|
||||||
|
)
|
||||||
|
# SHADOW 도 같은 이유로 스킵 (정당한 비교 불가)
|
||||||
|
continue
|
||||||
|
|
||||||
|
primary_result = self._run_single(primary, ctx)
|
||||||
|
if primary_result is None:
|
||||||
|
summary['failed'] += 1
|
||||||
|
# SHADOW 는 같은 입력이 있어야 비교 의미가 있으므로 이 사이클에선 스킵
|
||||||
|
continue
|
||||||
|
|
||||||
|
summary['executed'] += 1
|
||||||
|
ctx.shared[model_id] = primary_result
|
||||||
|
self._persist(primary_result, ctx.cycle_started_at)
|
||||||
|
|
||||||
|
# SHADOW/CHALLENGER 는 shared 주입 **금지** — 결과 persist 만
|
||||||
|
if shadows:
|
||||||
|
ran, failed = self._run_shadows(shadows, ctx)
|
||||||
|
summary['shadow_ran'] += ran
|
||||||
|
summary['shadow_failed'] += failed
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'DAGExecutor done: executed=%d failed=%d skip_deps=%d shadow_ran=%d shadow_failed=%d',
|
||||||
|
summary['executed'], summary['failed'],
|
||||||
|
summary['skipped_missing_deps'],
|
||||||
|
summary['shadow_ran'], summary['shadow_failed'],
|
||||||
|
)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _run_single(self, model: BaseDetectionModel, ctx: ModelContext) -> Optional[ModelResult]:
|
||||||
|
"""run_stage 로 감싸서 실패 격리 + 지속시간 계측."""
|
||||||
|
t0 = time.time()
|
||||||
|
result = run_stage(model.label(), model.run, ctx, required=False)
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
# duration_ms 가 비어있으면 여기서 채움
|
||||||
|
if not result.duration_ms:
|
||||||
|
result.duration_ms = int((time.time() - t0) * 1000)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _run_shadows(
|
||||||
|
self,
|
||||||
|
shadows: list[BaseDetectionModel],
|
||||||
|
ctx: ModelContext,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
ran = 0
|
||||||
|
failed = 0
|
||||||
|
if concurrent_shadows() and len(shadows) > 1:
|
||||||
|
with ThreadPoolExecutor(max_workers=min(4, len(shadows))) as pool:
|
||||||
|
futures = {pool.submit(self._run_single, s, ctx): s for s in shadows}
|
||||||
|
for fut in as_completed(futures):
|
||||||
|
s = futures[fut]
|
||||||
|
try:
|
||||||
|
r = fut.result()
|
||||||
|
except Exception:
|
||||||
|
logger.exception('shadow %s raised', s.label())
|
||||||
|
r = None
|
||||||
|
if r is None:
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
ran += 1
|
||||||
|
self._persist(r, ctx.cycle_started_at)
|
||||||
|
else:
|
||||||
|
for s in shadows:
|
||||||
|
r = self._run_single(s, ctx)
|
||||||
|
if r is None:
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
ran += 1
|
||||||
|
self._persist(r, ctx.cycle_started_at)
|
||||||
|
return ran, failed
|
||||||
|
|
||||||
|
def _persist(self, result: ModelResult, cycle_started_at) -> None:
|
||||||
|
conn = self._ctx_conn
|
||||||
|
try:
|
||||||
|
self._persist_fn(result, cycle_started_at, conn=conn)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
'failed to persist run_outputs for %s', result.model_id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self._persist_metrics_fn(result, cycle_started_at, conn=conn)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
'failed to persist metrics for %s', result.model_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 기본 persist 구현 — kcgdb 연결을 얻어서 직접 INSERT
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
_INSERT_RUN_OUTPUTS = """
|
||||||
|
INSERT INTO kcg.detection_model_run_outputs (
|
||||||
|
cycle_started_at, model_id, version_id, role,
|
||||||
|
input_ref, outputs, cycle_duration_ms
|
||||||
|
) VALUES %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
_INSERT_METRICS = """
|
||||||
|
INSERT INTO kcg.detection_model_metrics (
|
||||||
|
model_id, version_id, role, metric_key, metric_value, cycle_started_at
|
||||||
|
) VALUES %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_run_outputs(result: ModelResult, cycle_started_at, *, conn=None) -> None:
|
||||||
|
"""detection_model_run_outputs 배치 INSERT.
|
||||||
|
|
||||||
|
conn 이 전달되면 **재사용** (pool 중복 획득 방지, 커밋 책임은 호출자).
|
||||||
|
None 이면 자체적으로 kcgdb.get_conn() 으로 커넥션을 얻고 직접 커밋.
|
||||||
|
"""
|
||||||
|
if not result.outputs_per_input:
|
||||||
|
return
|
||||||
|
from psycopg2.extras import Json, execute_values
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
(
|
||||||
|
cycle_started_at,
|
||||||
|
result.model_id,
|
||||||
|
result.version_id,
|
||||||
|
result.role,
|
||||||
|
Json(input_ref or {}),
|
||||||
|
Json(output or {}),
|
||||||
|
result.duration_ms,
|
||||||
|
)
|
||||||
|
for input_ref, output in result.outputs_per_input
|
||||||
|
]
|
||||||
|
_execute_insert(_INSERT_RUN_OUTPUTS, rows, conn=conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_insert(sql: str, rows: list, *, conn=None) -> None:
|
||||||
|
"""execute_values 공통 — conn 재사용 시 commit 은 호출자 책임."""
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
from psycopg2.extras import execute_values
|
||||||
|
|
||||||
|
if conn is not None:
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
execute_values(cur, sql, rows, page_size=200)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
from db import kcgdb
|
||||||
|
with kcgdb.get_conn() as fresh_conn:
|
||||||
|
cur = fresh_conn.cursor()
|
||||||
|
try:
|
||||||
|
execute_values(cur, sql, rows, page_size=200)
|
||||||
|
fresh_conn.commit()
|
||||||
|
except Exception:
|
||||||
|
fresh_conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_metrics(result: ModelResult, cycle_started_at, *, conn=None) -> None:
|
||||||
|
"""detection_model_metrics 배치 INSERT. cycle_duration_ms 기본 포함.
|
||||||
|
|
||||||
|
conn 이 전달되면 재사용, None 이면 자체 get_conn().
|
||||||
|
metric_key VARCHAR(64) 초과는 경고 후 드롭 (silent 실패 방지).
|
||||||
|
"""
|
||||||
|
metrics = dict(result.metrics or {})
|
||||||
|
metrics.setdefault('cycle_duration_ms', float(result.duration_ms))
|
||||||
|
metrics.setdefault('output_count', float(len(result.outputs_per_input)))
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for key, val in metrics.items():
|
||||||
|
if val is None:
|
||||||
|
continue
|
||||||
|
if len(key) > _METRIC_KEY_MAXLEN:
|
||||||
|
logger.warning(
|
||||||
|
'metric_key %r exceeds VARCHAR(%d) — dropping (model=%s version=%s)',
|
||||||
|
key, _METRIC_KEY_MAXLEN, result.model_id, result.version_id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
rows.append((
|
||||||
|
result.model_id,
|
||||||
|
result.version_id,
|
||||||
|
result.role,
|
||||||
|
key,
|
||||||
|
float(val),
|
||||||
|
cycle_started_at,
|
||||||
|
))
|
||||||
|
_execute_insert(_INSERT_METRICS, rows, conn=conn)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['DAGExecutor']
|
||||||
29
prediction/models_core/feature_flag.py
Normal file
29
prediction/models_core/feature_flag.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""Detection Model Registry feature flag.
|
||||||
|
|
||||||
|
신·구 prediction 경로를 공존시키는 동안 환경변수로 토글한다.
|
||||||
|
초기 배포에서는 **0 (구 경로 유지)** 가 기본 — Phase 2 PoC 이 신·구 diff=0
|
||||||
|
동치성을 확인한 뒤 1 로 전환하는 별도 릴리즈를 내는 전략.
|
||||||
|
|
||||||
|
환경변수:
|
||||||
|
PREDICTION_USE_MODEL_REGISTRY '1' 이면 DAGExecutor 기반 신 경로 사용
|
||||||
|
PREDICTION_CONCURRENT_SHADOWS '1' 이면 SHADOW/CHALLENGER 를 스레드풀 동시 실행
|
||||||
|
(기본 0 — 순차 실행, psycopg2 pool 안전)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def _bool_env(key: str, default: str = '0') -> bool:
|
||||||
|
raw = os.getenv(key, default).strip().lower()
|
||||||
|
return raw in ('1', 'true', 'yes', 'on')
|
||||||
|
|
||||||
|
|
||||||
|
def use_model_registry() -> bool:
|
||||||
|
"""models_core Registry·Executor 기반 경로 사용 여부."""
|
||||||
|
return _bool_env('PREDICTION_USE_MODEL_REGISTRY', '0')
|
||||||
|
|
||||||
|
|
||||||
|
def concurrent_shadows() -> bool:
|
||||||
|
"""SHADOW/CHALLENGER 를 ThreadPoolExecutor 로 동시 실행할지."""
|
||||||
|
return _bool_env('PREDICTION_CONCURRENT_SHADOWS', '0')
|
||||||
176
prediction/models_core/params_loader.py
Normal file
176
prediction/models_core/params_loader.py
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
"""`detection_model_versions.params` JSONB 로더 + 5분 TTL 캐시.
|
||||||
|
|
||||||
|
- correlation_param_models 패턴의 일반화 — **매 사이클 재로드**를 기본으로,
|
||||||
|
다만 한 사이클 내에서 여러 번 조회되는 경우를 위해 TTL 캐시를 둔다.
|
||||||
|
- Registry 가 ACTIVE 버전 목록을 조회할 때와 executor 가 개별 버전 params 를
|
||||||
|
쓸 때 공통으로 사용.
|
||||||
|
|
||||||
|
반환 스키마:
|
||||||
|
VersionRow = {
|
||||||
|
'id': int, # detection_model_versions.id
|
||||||
|
'model_id': str,
|
||||||
|
'version': str,
|
||||||
|
'role': str, # PRIMARY / SHADOW / CHALLENGER
|
||||||
|
'params': dict, # JSONB
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Optional, TypedDict
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class VersionRow(TypedDict):
|
||||||
|
id: int
|
||||||
|
model_id: str
|
||||||
|
version: str
|
||||||
|
role: str
|
||||||
|
params: dict
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_TTL_SEC = 300 # 5분
|
||||||
|
|
||||||
|
|
||||||
|
class _ParamsCache:
|
||||||
|
"""간단 TTL 캐시 (프로세스 로컬).
|
||||||
|
|
||||||
|
thread-safe: Registry 재구성은 사이클 시작 스레드에서만 일어나지만
|
||||||
|
APScheduler 가 동시 job 을 허용할 수 있어 락으로 보호한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ttl_sec: int = _DEFAULT_TTL_SEC) -> None:
|
||||||
|
self._ttl = ttl_sec
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._rows: Optional[list[VersionRow]] = None
|
||||||
|
self._loaded_at: float = 0.0
|
||||||
|
|
||||||
|
def get(self, conn, *, force: bool = False) -> list[VersionRow]:
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
stale = (
|
||||||
|
self._rows is None
|
||||||
|
or force
|
||||||
|
or (now - self._loaded_at) > self._ttl
|
||||||
|
)
|
||||||
|
if stale:
|
||||||
|
self._rows = _fetch_active_versions(conn)
|
||||||
|
self._loaded_at = now
|
||||||
|
logger.info(
|
||||||
|
'params cache reloaded: %d ACTIVE versions (ttl=%ds)',
|
||||||
|
len(self._rows), self._ttl,
|
||||||
|
)
|
||||||
|
return list(self._rows or [])
|
||||||
|
|
||||||
|
def invalidate(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._rows = None
|
||||||
|
self._loaded_at = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
_cache = _ParamsCache()
|
||||||
|
|
||||||
|
|
||||||
|
def load_active_versions(conn, *, force_reload: bool = False) -> list[VersionRow]:
|
||||||
|
"""ACTIVE 상태의 모든 model_id × version 을 한 번에 조회.
|
||||||
|
|
||||||
|
model 단위로 PRIMARY 1 개 + SHADOW/CHALLENGER N 개가 섞여 반환될 수 있다.
|
||||||
|
Registry 가 그룹화를 담당.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: psycopg2 connection
|
||||||
|
force_reload: True 면 TTL 무시하고 DB 재조회
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
VersionRow 리스트
|
||||||
|
"""
|
||||||
|
return _cache.get(conn, force=force_reload)
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_cache() -> None:
|
||||||
|
"""운영자 API 가 version 을 promote·archive 한 직후 호출하면
|
||||||
|
다음 조회에서 즉시 DB 재로드가 일어난다.
|
||||||
|
"""
|
||||||
|
_cache.invalidate()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_active_versions(conn) -> list[VersionRow]:
|
||||||
|
"""SQL — kcg.detection_model_versions WHERE status='ACTIVE'.
|
||||||
|
|
||||||
|
JSONB 는 psycopg2 기본 설정에서 이미 dict 로 반환되지만, 안전을 위해
|
||||||
|
str 인 경우에도 json.loads 로 파싱한다.
|
||||||
|
"""
|
||||||
|
sql = """
|
||||||
|
SELECT v.id,
|
||||||
|
v.model_id,
|
||||||
|
v.version,
|
||||||
|
v.role,
|
||||||
|
v.params
|
||||||
|
FROM kcg.detection_model_versions v
|
||||||
|
JOIN kcg.detection_models m ON m.model_id = v.model_id
|
||||||
|
WHERE v.status = 'ACTIVE'
|
||||||
|
AND m.is_enabled = TRUE
|
||||||
|
ORDER BY v.model_id,
|
||||||
|
CASE v.role
|
||||||
|
WHEN 'PRIMARY' THEN 0
|
||||||
|
WHEN 'CHALLENGER' THEN 1
|
||||||
|
WHEN 'SHADOW' THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
v.id
|
||||||
|
"""
|
||||||
|
rows: list[VersionRow] = []
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute(sql)
|
||||||
|
for row in cur.fetchall():
|
||||||
|
vid, model_id, version, role, params = row
|
||||||
|
if isinstance(params, (bytes, bytearray)):
|
||||||
|
params = params.decode('utf-8')
|
||||||
|
if isinstance(params, str):
|
||||||
|
try:
|
||||||
|
params = json.loads(params)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning(
|
||||||
|
'detection_model_versions.id=%s params JSON decode failed — treated as {}',
|
||||||
|
vid,
|
||||||
|
)
|
||||||
|
params = {}
|
||||||
|
rows.append(
|
||||||
|
VersionRow(
|
||||||
|
id=int(vid),
|
||||||
|
model_id=str(model_id),
|
||||||
|
version=str(version),
|
||||||
|
role=str(role),
|
||||||
|
params=dict(params or {}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
|
||||||
|
def load_dependencies(conn) -> list[tuple[str, str, str]]:
|
||||||
|
"""detection_model_dependencies 전체 엣지 반환.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[(model_id, depends_on, input_key), ...]
|
||||||
|
"""
|
||||||
|
sql = """
|
||||||
|
SELECT model_id, depends_on, input_key
|
||||||
|
FROM kcg.detection_model_dependencies
|
||||||
|
ORDER BY model_id, depends_on, input_key
|
||||||
|
"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute(sql)
|
||||||
|
return [
|
||||||
|
(str(m), str(d), str(k))
|
||||||
|
for m, d, k in cur.fetchall()
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
5
prediction/models_core/registered/__init__.py
Normal file
5
prediction/models_core/registered/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""`BaseDetectionModel` 구현체 등록소.
|
||||||
|
|
||||||
|
Phase 1-2 기반 PR 에서는 실제 구현체가 없다 (Phase 2 에서 5 모델 PoC 추가).
|
||||||
|
이 디렉토리는 `ModelRegistry.discover_classes()` 가 `importlib` 으로 스캔한다.
|
||||||
|
"""
|
||||||
99
prediction/models_core/registered/dark_suspicion_model.py
Normal file
99
prediction/models_core/registered/dark_suspicion_model.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
"""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.
|
||||||
|
|
||||||
|
Phase 3 백엔드 API 연동 후 PRIMARY 로 승격하면 scheduler 도 이 모델을 호출하도록
|
||||||
|
전환 (현재는 ctx.shared 주입만, 기존 경로 영향 없음).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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,
|
||||||
|
ModelResult,
|
||||||
|
make_input_ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DarkSuspicionModel(BaseDetectionModel):
|
||||||
|
model_id = 'dark_suspicion'
|
||||||
|
depends_on: list[str] = []
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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')),
|
||||||
|
{
|
||||||
|
'score': int(score),
|
||||||
|
'tier': tier,
|
||||||
|
'patterns': patterns,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
return ModelResult(
|
||||||
|
model_id=self.model_id,
|
||||||
|
version_id=self.version_id,
|
||||||
|
version_str=self.version_str,
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['DarkSuspicionModel', 'DARK_SUSPICION_DEFAULT_PARAMS']
|
||||||
71
prediction/models_core/registered/gear_violation_model.py
Normal file
71
prediction/models_core/registered/gear_violation_model.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
"""gear_violation_g01_g06 — 어구 위반 G-01~G-06 종합 모델 (Phase 2 PoC #2).
|
||||||
|
|
||||||
|
기존 `algorithms.gear_violation.classify_gear_violations` 을 얇게 감싸는 Adapter.
|
||||||
|
scheduler.py 가 이미 이 함수를 호출해 AnalysisResult.features 에 결과를 저장하므로,
|
||||||
|
본 모델은 **ctx.inputs 의 AnalysisResult 들에서 그 결과를 관찰·집계**하는 역할.
|
||||||
|
|
||||||
|
입력:
|
||||||
|
- ctx.inputs 의 각 row (AnalysisResult asdict) 중 features.g_codes 가 비어있지 않은 선박.
|
||||||
|
|
||||||
|
출력:
|
||||||
|
- outputs_per_input: (input_ref, {g_codes, judgment, score}) — 원시 결과 snapshot
|
||||||
|
- metrics:
|
||||||
|
· evaluated_count : G-code 탐지된 선박 수
|
||||||
|
· g01_count ~ g06_count : 각 G-code 별 탐지 빈도
|
||||||
|
· pair_trawl_count : G-06 탐지
|
||||||
|
· closed_season_count : G-02 탐지
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from algorithms.gear_violation import GEAR_VIOLATION_DEFAULT_PARAMS
|
||||||
|
from models_core.base import BaseDetectionModel, ModelContext, ModelResult, make_input_ref
|
||||||
|
|
||||||
|
|
||||||
|
class GearViolationModel(BaseDetectionModel):
|
||||||
|
model_id = 'gear_violation_g01_g06'
|
||||||
|
depends_on: list[str] = []
|
||||||
|
|
||||||
|
def run(self, ctx: ModelContext) -> ModelResult:
|
||||||
|
outputs_per_input: list[tuple[dict, dict]] = []
|
||||||
|
counts = {f'g0{i}_count': 0 for i in range(1, 7)}
|
||||||
|
evaluated = 0
|
||||||
|
|
||||||
|
for row in ctx.inputs or []:
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
features = row.get('features') or {}
|
||||||
|
if not isinstance(features, dict):
|
||||||
|
continue
|
||||||
|
g_codes = features.get('g_codes') or []
|
||||||
|
if not g_codes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
evaluated += 1
|
||||||
|
for code in g_codes:
|
||||||
|
key = code.replace('-', '').lower() + '_count' # 'G-01' → 'g01_count'
|
||||||
|
if key in counts:
|
||||||
|
counts[key] += 1
|
||||||
|
|
||||||
|
outputs_per_input.append((
|
||||||
|
make_input_ref(row.get('mmsi'), row.get('analyzed_at')),
|
||||||
|
{
|
||||||
|
'g_codes': list(g_codes),
|
||||||
|
'gear_judgment': features.get('gear_judgment', ''),
|
||||||
|
'gear_violation_score': int(features.get('gear_violation_score') or 0),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
metrics = {'evaluated_count': float(evaluated)}
|
||||||
|
metrics.update({k: float(v) for k, v in counts.items()})
|
||||||
|
|
||||||
|
return ModelResult(
|
||||||
|
model_id=self.model_id,
|
||||||
|
version_id=self.version_id,
|
||||||
|
version_str=self.version_str,
|
||||||
|
role=self.role,
|
||||||
|
outputs_per_input=outputs_per_input,
|
||||||
|
metrics=metrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['GearViolationModel', 'GEAR_VIOLATION_DEFAULT_PARAMS']
|
||||||
65
prediction/models_core/registered/pair_trawl_model.py
Normal file
65
prediction/models_core/registered/pair_trawl_model.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
"""pair_trawl_tier — 쌍끌이 공조 tier 분류 관찰 어댑터 (Phase 2 PoC #5).
|
||||||
|
|
||||||
|
기존 `algorithms.pair_trawl` 이 STRONG/PROBABLE/SUSPECT tier 로 판정한 결과를
|
||||||
|
ctx.inputs (AnalysisResult) 에서 관찰 집계. 런타임 params override 는 후속 PR.
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
· evaluated_count : pair_trawl_detected=True 선박 수
|
||||||
|
· tier_{strong/probable/suspect}_count
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from algorithms.pair_trawl import PAIR_TRAWL_DEFAULT_PARAMS
|
||||||
|
from models_core.base import BaseDetectionModel, ModelContext, ModelResult, make_input_ref
|
||||||
|
|
||||||
|
|
||||||
|
class PairTrawlModel(BaseDetectionModel):
|
||||||
|
model_id = 'pair_trawl_tier'
|
||||||
|
depends_on: list[str] = []
|
||||||
|
|
||||||
|
def run(self, ctx: ModelContext) -> ModelResult:
|
||||||
|
outputs_per_input: list[tuple[dict, dict]] = []
|
||||||
|
tiers = {'STRONG': 0, 'PROBABLE': 0, 'SUSPECT': 0}
|
||||||
|
evaluated = 0
|
||||||
|
|
||||||
|
for row in ctx.inputs or []:
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
features = row.get('features') or {}
|
||||||
|
if not isinstance(features, dict):
|
||||||
|
continue
|
||||||
|
if features.get('pair_trawl_detected') is not True:
|
||||||
|
continue
|
||||||
|
|
||||||
|
evaluated += 1
|
||||||
|
tier = features.get('pair_tier', '')
|
||||||
|
if tier in tiers:
|
||||||
|
tiers[tier] += 1
|
||||||
|
|
||||||
|
outputs_per_input.append((
|
||||||
|
make_input_ref(row.get('mmsi'), row.get('analyzed_at')),
|
||||||
|
{
|
||||||
|
'pair_tier': tier,
|
||||||
|
'pair_type': features.get('pair_type'),
|
||||||
|
'pair_mmsi': features.get('pair_mmsi'),
|
||||||
|
'similarity': features.get('similarity'),
|
||||||
|
'confidence': features.get('confidence'),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
return ModelResult(
|
||||||
|
model_id=self.model_id,
|
||||||
|
version_id=self.version_id,
|
||||||
|
version_str=self.version_str,
|
||||||
|
role=self.role,
|
||||||
|
outputs_per_input=outputs_per_input,
|
||||||
|
metrics={
|
||||||
|
'evaluated_count': float(evaluated),
|
||||||
|
'tier_strong_count': float(tiers['STRONG']),
|
||||||
|
'tier_probable_count': float(tiers['PROBABLE']),
|
||||||
|
'tier_suspect_count': float(tiers['SUSPECT']),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['PairTrawlModel', 'PAIR_TRAWL_DEFAULT_PARAMS']
|
||||||
66
prediction/models_core/registered/risk_composite_model.py
Normal file
66
prediction/models_core/registered/risk_composite_model.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"""risk_composite — 종합 위험도 관찰 어댑터 (Phase 2 PoC #4).
|
||||||
|
|
||||||
|
현재 `algorithms.risk` 의 compute_*_risk_score 들이 inline 숫자로 점수를 계산하므로
|
||||||
|
이 단계에서는 카탈로그 등록 + AnalysisResult 의 risk_score/risk_level 을 집계 관찰만.
|
||||||
|
런타임 params override 는 후속 리팩토링 PR 에서 활성화.
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
· evaluated_count : 전체 관찰 수
|
||||||
|
· avg_risk_score
|
||||||
|
· tier_{critical/high/medium/low}_count
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from algorithms.risk import RISK_COMPOSITE_DEFAULT_PARAMS
|
||||||
|
from models_core.base import BaseDetectionModel, ModelContext, ModelResult, make_input_ref
|
||||||
|
|
||||||
|
|
||||||
|
class RiskCompositeModel(BaseDetectionModel):
|
||||||
|
model_id = 'risk_composite'
|
||||||
|
depends_on: list[str] = []
|
||||||
|
|
||||||
|
def run(self, ctx: ModelContext) -> ModelResult:
|
||||||
|
outputs_per_input: list[tuple[dict, dict]] = []
|
||||||
|
tiers = {'CRITICAL': 0, 'HIGH': 0, 'MEDIUM': 0, 'LOW': 0}
|
||||||
|
score_sum = 0.0
|
||||||
|
evaluated = 0
|
||||||
|
|
||||||
|
for row in ctx.inputs or []:
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
score = row.get('risk_score')
|
||||||
|
level = row.get('risk_level', '')
|
||||||
|
if score is None:
|
||||||
|
continue
|
||||||
|
evaluated += 1
|
||||||
|
score_sum += float(score)
|
||||||
|
if level in tiers:
|
||||||
|
tiers[level] += 1
|
||||||
|
outputs_per_input.append((
|
||||||
|
make_input_ref(row.get('mmsi'), row.get('analyzed_at')),
|
||||||
|
{
|
||||||
|
'risk_score': int(score),
|
||||||
|
'risk_level': level,
|
||||||
|
'vessel_type': row.get('vessel_type'),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
avg = score_sum / evaluated if evaluated else 0.0
|
||||||
|
return ModelResult(
|
||||||
|
model_id=self.model_id,
|
||||||
|
version_id=self.version_id,
|
||||||
|
version_str=self.version_str,
|
||||||
|
role=self.role,
|
||||||
|
outputs_per_input=outputs_per_input,
|
||||||
|
metrics={
|
||||||
|
'evaluated_count': float(evaluated),
|
||||||
|
'avg_risk_score': round(avg, 2),
|
||||||
|
'tier_critical_count': float(tiers['CRITICAL']),
|
||||||
|
'tier_high_count': float(tiers['HIGH']),
|
||||||
|
'tier_medium_count': float(tiers['MEDIUM']),
|
||||||
|
'tier_low_count': float(tiers['LOW']),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['RiskCompositeModel', 'RISK_COMPOSITE_DEFAULT_PARAMS']
|
||||||
67
prediction/models_core/registered/transshipment_model.py
Normal file
67
prediction/models_core/registered/transshipment_model.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"""transshipment_5stage — 5단계 환적 탐지 관찰 어댑터 (Phase 2 PoC #3).
|
||||||
|
|
||||||
|
현재 `algorithms.transshipment.detect_transshipment` 의 내부 헬퍼가 모듈 레벨
|
||||||
|
상수를 직접 참조하므로 이번 단계에서는 **카탈로그 등록 + 관찰 수집** 만 담당.
|
||||||
|
런타임 params override 는 후속 리팩토링 PR 에서 헬퍼 시그니처 확장과 함께 활성화.
|
||||||
|
|
||||||
|
입력: ctx.inputs (AnalysisResult asdict). `transship_suspect=True` 인 선박을 집계.
|
||||||
|
출력:
|
||||||
|
- outputs_per_input: (input_ref, {pair_mmsi, duration_min, tier, score, pair_type})
|
||||||
|
- metrics:
|
||||||
|
· evaluated_count : transship_suspect True 선박 수
|
||||||
|
· tier_critical_count / tier_high_count / tier_medium_count
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from algorithms.transshipment import TRANSSHIPMENT_DEFAULT_PARAMS
|
||||||
|
from models_core.base import BaseDetectionModel, ModelContext, ModelResult, make_input_ref
|
||||||
|
|
||||||
|
|
||||||
|
class TransshipmentModel(BaseDetectionModel):
|
||||||
|
model_id = 'transshipment_5stage'
|
||||||
|
depends_on: list[str] = []
|
||||||
|
|
||||||
|
def run(self, ctx: ModelContext) -> ModelResult:
|
||||||
|
outputs_per_input: list[tuple[dict, dict]] = []
|
||||||
|
tier_counts = {'CRITICAL': 0, 'HIGH': 0, 'MEDIUM': 0, 'LOW': 0}
|
||||||
|
evaluated = 0
|
||||||
|
|
||||||
|
for row in ctx.inputs or []:
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
if not row.get('transship_suspect'):
|
||||||
|
continue
|
||||||
|
evaluated += 1
|
||||||
|
features = row.get('features') or {}
|
||||||
|
if not isinstance(features, dict):
|
||||||
|
features = {}
|
||||||
|
tier = features.get('transship_tier', '')
|
||||||
|
if tier in tier_counts:
|
||||||
|
tier_counts[tier] += 1
|
||||||
|
outputs_per_input.append((
|
||||||
|
make_input_ref(row.get('mmsi'), row.get('analyzed_at')),
|
||||||
|
{
|
||||||
|
'pair_mmsi': row.get('transship_pair_mmsi'),
|
||||||
|
'duration_min': row.get('transship_duration_min'),
|
||||||
|
'tier': tier,
|
||||||
|
'score': features.get('transship_score'),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
return ModelResult(
|
||||||
|
model_id=self.model_id,
|
||||||
|
version_id=self.version_id,
|
||||||
|
version_str=self.version_str,
|
||||||
|
role=self.role,
|
||||||
|
outputs_per_input=outputs_per_input,
|
||||||
|
metrics={
|
||||||
|
'evaluated_count': float(evaluated),
|
||||||
|
'tier_critical_count': float(tier_counts['CRITICAL']),
|
||||||
|
'tier_high_count': float(tier_counts['HIGH']),
|
||||||
|
'tier_medium_count': float(tier_counts['MEDIUM']),
|
||||||
|
'tier_low_count': float(tier_counts['LOW']),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['TransshipmentModel', 'TRANSSHIPMENT_DEFAULT_PARAMS']
|
||||||
282
prediction/models_core/registry.py
Normal file
282
prediction/models_core/registry.py
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
"""ModelRegistry — ACTIVE 버전 전체 인스턴스화 + DAG 검증 + 실행 플랜 생성.
|
||||||
|
|
||||||
|
역할:
|
||||||
|
1. `prediction/models_core/registered/` 를 스캔하여 BaseDetectionModel 서브클래스를 모음
|
||||||
|
2. DB 에서 ACTIVE 버전 목록(PRIMARY + SHADOW/CHALLENGER) 을 읽어 **버전별 인스턴스**를 생성
|
||||||
|
3. detection_model_dependencies + 클래스 `depends_on` 을 합쳐 DAG 를 구성하고 순환 검출
|
||||||
|
4. Executor 가 쓸 topological 실행 플랜(ExecutionPlan) 을 반환
|
||||||
|
|
||||||
|
주의:
|
||||||
|
- 클래스에 `model_id` 가 정의돼 있어도 DB 에 해당 레코드가 없으면 인스턴스화하지 않음
|
||||||
|
(즉 DB 가 Single Source of Truth, 코드는 "구현 있음" 선언 역할)
|
||||||
|
- DB 에 model_id 가 있고 코드에 클래스가 없으면 경고 로그 후 **스킵** (부분 배포 허용)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import logging
|
||||||
|
import pkgutil
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Iterable, Optional, Type
|
||||||
|
|
||||||
|
from .base import (
|
||||||
|
ALLOWED_ROLES,
|
||||||
|
ROLE_CHALLENGER,
|
||||||
|
ROLE_PRIMARY,
|
||||||
|
ROLE_SHADOW,
|
||||||
|
BaseDetectionModel,
|
||||||
|
)
|
||||||
|
from .params_loader import VersionRow, load_active_versions, load_dependencies
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExecutionPlan:
|
||||||
|
"""Executor 가 따를 실행 순서.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
topo_order: PRIMARY 기준 topological order (model_id 문자열 리스트).
|
||||||
|
SHADOW/CHALLENGER 는 자기 model_id 의 PRIMARY 와 같은 슬롯에서 돈다.
|
||||||
|
primaries: model_id -> BaseDetectionModel 인스턴스 (PRIMARY)
|
||||||
|
shadows: model_id -> list[BaseDetectionModel] (SHADOW + CHALLENGER)
|
||||||
|
edges: DAG 디버깅용 (model_id -> set(depends_on))
|
||||||
|
"""
|
||||||
|
topo_order: list[str] = field(default_factory=list)
|
||||||
|
primaries: dict[str, BaseDetectionModel] = field(default_factory=dict)
|
||||||
|
shadows: dict[str, list[BaseDetectionModel]] = field(default_factory=lambda: defaultdict(list))
|
||||||
|
edges: dict[str, set[str]] = field(default_factory=lambda: defaultdict(set))
|
||||||
|
|
||||||
|
|
||||||
|
class DAGCycleError(RuntimeError):
|
||||||
|
"""모델 의존성 그래프에 순환이 있을 때."""
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRegistry:
|
||||||
|
"""ACTIVE 버전 인스턴스 저장소 + Plan 제공자."""
|
||||||
|
|
||||||
|
_DEFAULT_REGISTERED_PKG = 'models_core.registered'
|
||||||
|
|
||||||
|
def __init__(self, registered_pkg: str = _DEFAULT_REGISTERED_PKG) -> None:
|
||||||
|
self._registered_pkg = registered_pkg
|
||||||
|
self._classes: dict[str, Type[BaseDetectionModel]] = {}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 클래스 discovery
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def discover_classes(self) -> dict[str, Type[BaseDetectionModel]]:
|
||||||
|
"""`registered/` 하위 모듈 auto-import + BaseDetectionModel 서브클래스 수집.
|
||||||
|
|
||||||
|
동일 model_id 가 여러 클래스에서 중복 선언되면 ValueError.
|
||||||
|
"""
|
||||||
|
self._classes = {}
|
||||||
|
try:
|
||||||
|
pkg = importlib.import_module(self._registered_pkg)
|
||||||
|
except ImportError:
|
||||||
|
logger.warning('registered package %s not importable', self._registered_pkg)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
for mod_info in pkgutil.iter_modules(pkg.__path__, prefix=f'{self._registered_pkg}.'):
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(mod_info.name)
|
||||||
|
except Exception:
|
||||||
|
logger.exception('failed to import %s', mod_info.name)
|
||||||
|
continue
|
||||||
|
for attr_name in dir(module):
|
||||||
|
obj = getattr(module, attr_name)
|
||||||
|
if not isinstance(obj, type):
|
||||||
|
continue
|
||||||
|
if obj is BaseDetectionModel:
|
||||||
|
continue
|
||||||
|
if not issubclass(obj, BaseDetectionModel):
|
||||||
|
continue
|
||||||
|
mid = getattr(obj, 'model_id', '')
|
||||||
|
if not mid:
|
||||||
|
continue
|
||||||
|
if mid in self._classes and self._classes[mid] is not obj:
|
||||||
|
raise ValueError(
|
||||||
|
f'duplicate model_id {mid!r}: '
|
||||||
|
f'{self._classes[mid].__name__} vs {obj.__name__}'
|
||||||
|
)
|
||||||
|
self._classes[mid] = obj
|
||||||
|
logger.info('discovered %d detection model classes: %s',
|
||||||
|
len(self._classes), sorted(self._classes.keys()))
|
||||||
|
return dict(self._classes)
|
||||||
|
|
||||||
|
def register_class(self, cls: Type[BaseDetectionModel]) -> None:
|
||||||
|
"""테스트·수동 등록용."""
|
||||||
|
mid = getattr(cls, 'model_id', '')
|
||||||
|
if not mid:
|
||||||
|
raise ValueError(f'{cls.__name__}.model_id is empty')
|
||||||
|
self._classes[mid] = cls
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Plan 생성
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def build_plan(self, conn, *, force_reload: bool = False) -> ExecutionPlan:
|
||||||
|
"""DB ACTIVE 버전 + 클래스 + DAG 를 합쳐 ExecutionPlan 생성."""
|
||||||
|
versions = load_active_versions(conn, force_reload=force_reload)
|
||||||
|
edges = self._collect_edges(conn, versions)
|
||||||
|
plan = self._instantiate(versions, edges)
|
||||||
|
plan.topo_order = self._topo_sort(plan)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
def build_plan_from_rows(
|
||||||
|
self,
|
||||||
|
versions: Iterable[VersionRow],
|
||||||
|
dependencies: Iterable[tuple[str, str, str]] = (),
|
||||||
|
) -> ExecutionPlan:
|
||||||
|
"""테스트용 — DB 없이 in-memory rows 만으로 Plan 생성."""
|
||||||
|
edges: dict[str, set[str]] = defaultdict(set)
|
||||||
|
active_ids = {v['model_id'] for v in versions}
|
||||||
|
for model_id, depends_on, _key in dependencies:
|
||||||
|
if model_id in active_ids and depends_on in active_ids:
|
||||||
|
edges[model_id].add(depends_on)
|
||||||
|
# 클래스 선언 depends_on 도 합류
|
||||||
|
for v in versions:
|
||||||
|
cls = self._classes.get(v['model_id'])
|
||||||
|
if cls is None:
|
||||||
|
continue
|
||||||
|
for dep in getattr(cls, 'depends_on', []) or []:
|
||||||
|
if dep in active_ids:
|
||||||
|
edges[v['model_id']].add(dep)
|
||||||
|
|
||||||
|
plan = self._instantiate(versions, edges)
|
||||||
|
plan.topo_order = self._topo_sort(plan)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 내부
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _collect_edges(
|
||||||
|
self,
|
||||||
|
conn,
|
||||||
|
versions: list[VersionRow],
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
"""DB dependencies + 클래스 선언 depends_on 합산."""
|
||||||
|
edges: dict[str, set[str]] = defaultdict(set)
|
||||||
|
active_ids = {v['model_id'] for v in versions}
|
||||||
|
|
||||||
|
try:
|
||||||
|
for model_id, depends_on, _key in load_dependencies(conn):
|
||||||
|
if model_id in active_ids and depends_on in active_ids:
|
||||||
|
edges[model_id].add(depends_on)
|
||||||
|
except Exception:
|
||||||
|
logger.exception('load_dependencies failed — proceeding with class-level depends_on only')
|
||||||
|
|
||||||
|
for v in versions:
|
||||||
|
cls = self._classes.get(v['model_id'])
|
||||||
|
if cls is None:
|
||||||
|
continue
|
||||||
|
for dep in getattr(cls, 'depends_on', []) or []:
|
||||||
|
if dep in active_ids:
|
||||||
|
edges[v['model_id']].add(dep)
|
||||||
|
return edges
|
||||||
|
|
||||||
|
def _instantiate(
|
||||||
|
self,
|
||||||
|
versions: Iterable[VersionRow],
|
||||||
|
edges: dict[str, set[str]],
|
||||||
|
) -> ExecutionPlan:
|
||||||
|
plan = ExecutionPlan()
|
||||||
|
plan.edges = defaultdict(set, {k: set(v) for k, v in edges.items()})
|
||||||
|
|
||||||
|
for v in versions:
|
||||||
|
mid = v['model_id']
|
||||||
|
role = v['role']
|
||||||
|
if role not in ALLOWED_ROLES:
|
||||||
|
logger.warning(
|
||||||
|
'skip version id=%s role=%r not in %s',
|
||||||
|
v['id'], role, ALLOWED_ROLES,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
cls = self._classes.get(mid)
|
||||||
|
if cls is None:
|
||||||
|
logger.warning(
|
||||||
|
'model_id=%s has ACTIVE version %s(role=%s) but no registered class — skipping',
|
||||||
|
mid, v['version'], role,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
inst = cls(
|
||||||
|
version_id=v['id'],
|
||||||
|
version_str=v['version'],
|
||||||
|
role=role,
|
||||||
|
params=v['params'],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
'failed to instantiate %s version_id=%s — skipping',
|
||||||
|
cls.__name__, v['id'],
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == ROLE_PRIMARY:
|
||||||
|
if mid in plan.primaries:
|
||||||
|
# DB UNIQUE INDEX 가 보장하지만 방어적으로
|
||||||
|
logger.error(
|
||||||
|
'duplicate PRIMARY for %s (existing id=%s, new id=%s) — keeping existing',
|
||||||
|
mid, plan.primaries[mid].version_id, v['id'],
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
plan.primaries[mid] = inst
|
||||||
|
else: # SHADOW / CHALLENGER
|
||||||
|
plan.shadows[mid].append(inst)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _topo_sort(plan: ExecutionPlan) -> list[str]:
|
||||||
|
"""PRIMARY 노드 기준 topological order. 순환 시 DAGCycleError."""
|
||||||
|
nodes = set(plan.primaries.keys()) | set(plan.shadows.keys())
|
||||||
|
# SHADOW-only 모델도 노드로 취급 (PRIMARY 미등록이면 Executor 가 skip)
|
||||||
|
in_degree: dict[str, int] = {n: 0 for n in nodes}
|
||||||
|
adj: dict[str, set[str]] = defaultdict(set)
|
||||||
|
for node, deps in plan.edges.items():
|
||||||
|
if node not in nodes:
|
||||||
|
continue
|
||||||
|
for dep in deps:
|
||||||
|
if dep not in nodes:
|
||||||
|
continue
|
||||||
|
adj[dep].add(node)
|
||||||
|
in_degree[node] = in_degree.get(node, 0) + 1
|
||||||
|
|
||||||
|
order: list[str] = []
|
||||||
|
queue = deque(sorted([n for n, d in in_degree.items() if d == 0]))
|
||||||
|
while queue:
|
||||||
|
n = queue.popleft()
|
||||||
|
order.append(n)
|
||||||
|
for nxt in sorted(adj[n]):
|
||||||
|
in_degree[nxt] -= 1
|
||||||
|
if in_degree[nxt] == 0:
|
||||||
|
queue.append(nxt)
|
||||||
|
|
||||||
|
if len(order) != len(nodes):
|
||||||
|
remaining = [n for n in nodes if n not in order]
|
||||||
|
raise DAGCycleError(
|
||||||
|
f'DAG cycle detected among detection models: {sorted(remaining)}'
|
||||||
|
)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
# 편의: 싱글톤 패턴 (운영 환경에서 주로 한 인스턴스만 씀)
|
||||||
|
_registry_singleton: Optional[ModelRegistry] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_registry() -> ModelRegistry:
|
||||||
|
global _registry_singleton
|
||||||
|
if _registry_singleton is None:
|
||||||
|
_registry_singleton = ModelRegistry()
|
||||||
|
_registry_singleton.discover_classes()
|
||||||
|
return _registry_singleton
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'ModelRegistry',
|
||||||
|
'ExecutionPlan',
|
||||||
|
'DAGCycleError',
|
||||||
|
'get_registry',
|
||||||
|
'ROLE_PRIMARY',
|
||||||
|
'ROLE_SHADOW',
|
||||||
|
'ROLE_CHALLENGER',
|
||||||
|
]
|
||||||
93
prediction/models_core/seeds/README.md
Normal file
93
prediction/models_core/seeds/README.md
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
# Detection Model Seeds
|
||||||
|
|
||||||
|
Phase 2 PoC 모델을 V034 `detection_models` + `detection_model_versions` 에 seed 하는 SQL.
|
||||||
|
|
||||||
|
## 현재 seed 대상
|
||||||
|
|
||||||
|
| 파일 | 모델 | tier | 모드 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `v1_dark_suspicion.sql` | `dark_suspicion` (DARK_VESSEL) | 3 | **런타임 override 완성** |
|
||||||
|
| `v1_gear_violation.sql` | `gear_violation_g01_g06` (GEAR) | 4 | **런타임 override 완성** |
|
||||||
|
| `v1_transshipment.sql` | `transshipment_5stage` (TRANSSHIP) | 4 | 카탈로그 + 관찰 |
|
||||||
|
| `v1_risk_composite.sql` | `risk_composite` (META) | 3 | 카탈로그 + 관찰 |
|
||||||
|
| `v1_pair_trawl.sql` | `pair_trawl_tier` (GEAR) | 4 | 카탈로그 + 관찰 |
|
||||||
|
| `v1_phase2_all.sql` | 5 모델 일괄 | — | \i 로 위 5개 순차 실행 |
|
||||||
|
|
||||||
|
### "런타임 override 완성" vs "카탈로그 + 관찰"
|
||||||
|
|
||||||
|
- **런타임 override 완성** (`dark_suspicion`, `gear_violation_g01_g06`): 알고리즘 함수가 `params: dict | None = None` 인자를 받고, ACTIVE 버전의 JSONB 를 적용해 실제 가중치·임계값이 교체된다. 운영자가 version 을 ACTIVE 로 승격하면 **다음 사이클 결과가 바뀐다**.
|
||||||
|
- **카탈로그 + 관찰** (`transshipment_5stage`, `risk_composite`, `pair_trawl_tier`): 내부 헬퍼 함수들이 모듈 레벨 상수를 직접 참조하여 범위가 큰 리팩토링이 필요. 이번 단계에서는 **DEFAULT_PARAMS 를 DB 카탈로그에 노출 + Adapter 로 결과 관찰 수집**까지만. 런타임 실제 교체는 후속 리팩토링 PR 에서 헬퍼에 params 전파를 완성하면 활성화된다.
|
||||||
|
|
||||||
|
## 실행 방법
|
||||||
|
|
||||||
|
**중요**: 본 seed 파일들은 `BEGIN`/`COMMIT` 을 포함하지 않는다. 호출자가 트랜잭션을 관리한다.
|
||||||
|
|
||||||
|
### (A) 운영 적용 — 단일 트랜잭션 자동 래핑
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PGPASSWORD=... psql -h <host> -U kcg-app -d kcgaidb \
|
||||||
|
-v ON_ERROR_STOP=1 -1 \
|
||||||
|
-f prediction/models_core/seeds/v1_dark_suspicion.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
`-1` 플래그가 파일 전체를 한 트랜잭션으로 묶어 어느 INSERT 실패 시 전부 롤백.
|
||||||
|
|
||||||
|
### (B) Dry-run — 실제 반영 없이 SQL 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PGPASSWORD=... psql -h <host> -U kcg-app -d kcgaidb -v ON_ERROR_STOP=1 <<'SQL'
|
||||||
|
BEGIN;
|
||||||
|
\i prediction/models_core/seeds/v1_dark_suspicion.sql
|
||||||
|
SELECT version, status, params->'tier_thresholds'
|
||||||
|
FROM kcg.detection_model_versions WHERE model_id='dark_suspicion';
|
||||||
|
ROLLBACK;
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚠️ 사용 금지 패턴
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 절대 금지 — 각 -c/-f 가 별도 세션이라 실제로 INSERT 됨
|
||||||
|
psql -c 'BEGIN;' -f v1_dark_suspicion.sql -c 'ROLLBACK;'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 운영자 승격 절차
|
||||||
|
|
||||||
|
1. seed 결과는 `status=DRAFT role=NULL` → prediction 은 **참조하지 않음**
|
||||||
|
(`params_loader` 는 `WHERE status='ACTIVE' AND is_enabled=TRUE`).
|
||||||
|
2. Phase 3 백엔드 API 배포 후 운영자가 다음 중 하나로 승격:
|
||||||
|
- `POST /api/ai/detection-models/{modelId}/versions/{versionId}/activate?role=SHADOW` — 관찰 전용
|
||||||
|
- `POST /api/ai/detection-models/{modelId}/versions/{versionId}/activate?role=PRIMARY` — 운영 반영
|
||||||
|
- `POST /api/ai/detection-models/{modelId}/versions/{versionId}/promote-primary` — SHADOW→PRIMARY 승격 (기존 PRIMARY 자동 ARCHIVED)
|
||||||
|
3. prediction 은 다음 사이클 시작 시 `params_loader` TTL(5분) 만료 후 자동 적재.
|
||||||
|
4. `PREDICTION_USE_MODEL_REGISTRY=1` 환경변수로 재기동해야 실제 실행
|
||||||
|
(기본 `0` 은 구 경로 유지, Phase 2 diff=0 검증 전까지 안전).
|
||||||
|
|
||||||
|
## 동치성 검증
|
||||||
|
|
||||||
|
각 모델의 `params` JSONB 는 Python 소스의 `*_DEFAULT_PARAMS` 상수와 1:1 일치.
|
||||||
|
정적 검증은 `prediction/tests/test_dark_suspicion_params.py::test_seed_sql_values_match_python_default` 가 담당.
|
||||||
|
|
||||||
|
런타임 검증(신·구 경로 diff):
|
||||||
|
1. 같은 모델에 v1.0.0 (DEFAULT) 를 PRIMARY 로 seed
|
||||||
|
2. (선택) v1.0.0-shadow 를 **동일 params** 로 SHADOW 로 seed
|
||||||
|
3. `PREDICTION_USE_MODEL_REGISTRY=1` 로 5분 사이클 1회 실행
|
||||||
|
4. `kcg.v_detection_model_comparison` 뷰에서 PRIMARY↔SHADOW `outputs` 비교 → 전 입력 동일이어야 함
|
||||||
|
|
||||||
|
## 롤백
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 한 모델만
|
||||||
|
DELETE FROM kcg.detection_model_versions WHERE model_id = 'dark_suspicion';
|
||||||
|
DELETE FROM kcg.detection_models WHERE model_id = 'dark_suspicion';
|
||||||
|
|
||||||
|
-- Phase 2 전체 (후속 PR 반영 이후)
|
||||||
|
DELETE FROM kcg.detection_model_versions WHERE model_id IN (
|
||||||
|
'dark_suspicion', 'gear_violation_g01_g06', 'transshipment_5stage',
|
||||||
|
'risk_composite', 'pair_trawl_tier'
|
||||||
|
);
|
||||||
|
DELETE FROM kcg.detection_models WHERE model_id IN (
|
||||||
|
'dark_suspicion', 'gear_violation_g01_g06', 'transshipment_5stage',
|
||||||
|
'risk_composite', 'pair_trawl_tier'
|
||||||
|
);
|
||||||
|
```
|
||||||
97
prediction/models_core/seeds/v1_dark_suspicion.sql
Normal file
97
prediction/models_core/seeds/v1_dark_suspicion.sql
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
-- Phase 2 PoC #1 — dark_suspicion 모델 seed
|
||||||
|
--
|
||||||
|
-- ⚠️ 트랜잭션 제어는 호출자가 담당한다. 본 파일에는 BEGIN/COMMIT 가 없다.
|
||||||
|
-- 반드시 아래 중 하나의 방식으로 실행하라:
|
||||||
|
--
|
||||||
|
-- (A) 단일 트랜잭션 자동 래핑 (운영 적용):
|
||||||
|
-- psql -v ON_ERROR_STOP=1 -1 -f prediction/models_core/seeds/v1_dark_suspicion.sql
|
||||||
|
--
|
||||||
|
-- (B) dry-run (실제 반영 없이 SQL 검증만):
|
||||||
|
-- psql -v ON_ERROR_STOP=1 <<SQL
|
||||||
|
-- BEGIN;
|
||||||
|
-- \i prediction/models_core/seeds/v1_dark_suspicion.sql
|
||||||
|
-- -- 결과 확인 쿼리
|
||||||
|
-- ROLLBACK;
|
||||||
|
-- SQL
|
||||||
|
--
|
||||||
|
-- psql 의 `-c BEGIN -f FILE -c ROLLBACK` 조합은 각 -c/-f 가 **별도 세션** 이라
|
||||||
|
-- 트랜잭션이 격리되지 않아 실제 INSERT 된다. 절대 사용 금지.
|
||||||
|
--
|
||||||
|
-- 결과: kcg.detection_models 에 'dark_suspicion' 1 행 INSERT
|
||||||
|
-- kcg.detection_model_versions 에 v1.0.0 status=DRAFT role=NULL 1 행 INSERT
|
||||||
|
--
|
||||||
|
-- 동치성 보장: params JSONB 는 prediction/algorithms/dark_vessel.py
|
||||||
|
-- DARK_SUSPICION_DEFAULT_PARAMS 의 값과 1:1 일치 (Python 상수가 SSOT).
|
||||||
|
--
|
||||||
|
-- Phase 3 백엔드 API 배포 후 운영자가 TESTING→ACTIVE(role=PRIMARY) 로 승격하면
|
||||||
|
-- prediction 이 다음 사이클에서 params_loader TTL(5분) 만료 후 자동 적재한다.
|
||||||
|
-- `PREDICTION_USE_MODEL_REGISTRY=1` 환경변수가 필요하다 (기본 0).
|
||||||
|
--
|
||||||
|
-- 롤백:
|
||||||
|
-- DELETE FROM kcg.detection_model_versions
|
||||||
|
-- WHERE model_id = 'dark_suspicion' AND version = '1.0.0';
|
||||||
|
-- DELETE FROM kcg.detection_models WHERE model_id = 'dark_suspicion';
|
||||||
|
|
||||||
|
INSERT INTO kcg.detection_models (
|
||||||
|
model_id, display_name, tier, category,
|
||||||
|
description, entry_module, entry_callable, is_enabled
|
||||||
|
) VALUES (
|
||||||
|
'dark_suspicion',
|
||||||
|
'다크 베셀 의심도 (패턴 기반 P1~P11 + 커버리지 감점)',
|
||||||
|
3,
|
||||||
|
'DARK_VESSEL',
|
||||||
|
'의도적 AIS OFF 의심 점수(0~100)를 산출. gap_info + is_permitted + 반복 이력 + 선종/항해 상태/heading-COG 불일치/커버리지 감점 기반. 패턴 가중치는 params.weights 에서 전부 조정 가능하다.',
|
||||||
|
'models_core.registered.dark_suspicion_model',
|
||||||
|
'DarkSuspicionModel',
|
||||||
|
TRUE
|
||||||
|
) ON CONFLICT (model_id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO kcg.detection_model_versions (
|
||||||
|
model_id, version, status, role, params, notes
|
||||||
|
) VALUES (
|
||||||
|
'dark_suspicion',
|
||||||
|
'1.0.0',
|
||||||
|
'DRAFT',
|
||||||
|
NULL,
|
||||||
|
$json${
|
||||||
|
"sog_thresholds": {
|
||||||
|
"moving": 5.0,
|
||||||
|
"slow_moving": 2.0,
|
||||||
|
"underway_deliberate": 3.0
|
||||||
|
},
|
||||||
|
"heading_cog_mismatch_deg": 60.0,
|
||||||
|
"weights": {
|
||||||
|
"P1_moving_off": 25,
|
||||||
|
"P1_slow_moving_off": 15,
|
||||||
|
"P2_sensitive_zone": 25,
|
||||||
|
"P2_special_zone": 15,
|
||||||
|
"P3_repeat_high": 30,
|
||||||
|
"P3_repeat_low": 15,
|
||||||
|
"P3_recent_dark": 10,
|
||||||
|
"P4_distance_anomaly": 20,
|
||||||
|
"P5_daytime_fishing_off": 15,
|
||||||
|
"P6_teleport_before_gap": 15,
|
||||||
|
"P7_unpermitted": 10,
|
||||||
|
"P8_very_long_gap": 15,
|
||||||
|
"P8_long_gap": 10,
|
||||||
|
"P9_fishing_vessel_dark": 10,
|
||||||
|
"P9_cargo_natural_gap": -10,
|
||||||
|
"P10_underway_deliberate": 20,
|
||||||
|
"P10_anchored_natural": -15,
|
||||||
|
"P11_heading_cog_mismatch": 15,
|
||||||
|
"out_of_coverage": -50
|
||||||
|
},
|
||||||
|
"repeat_thresholds": {"h7_high": 3, "h7_low": 2, "h24_recent": 1},
|
||||||
|
"gap_min_thresholds": {"very_long": 360, "long": 180},
|
||||||
|
"p4_distance_multiplier": 2.0,
|
||||||
|
"p5_daytime_range": [6, 18],
|
||||||
|
"tier_thresholds": {"critical": 70, "high": 50, "watch": 30}
|
||||||
|
}$json$::jsonb,
|
||||||
|
'Phase 2 PoC #1 seed. Python DARK_SUSPICION_DEFAULT_PARAMS 와 1:1 일치 — 신·구 경로 diff=0 전제.'
|
||||||
|
) ON CONFLICT (model_id, version) DO NOTHING;
|
||||||
|
|
||||||
|
-- 확인
|
||||||
|
SELECT model_id, is_enabled,
|
||||||
|
(SELECT count(*) FROM kcg.detection_model_versions v WHERE v.model_id = m.model_id) AS versions
|
||||||
|
FROM kcg.detection_models m
|
||||||
|
WHERE model_id = 'dark_suspicion';
|
||||||
65
prediction/models_core/seeds/v1_gear_violation.sql
Normal file
65
prediction/models_core/seeds/v1_gear_violation.sql
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
-- Phase 2 PoC #2 — gear_violation_g01_g06 모델 seed
|
||||||
|
--
|
||||||
|
-- 트랜잭션 제어는 호출자가 담당. BEGIN/COMMIT 없음. 실행 방법은 seeds/README.md 참조.
|
||||||
|
--
|
||||||
|
-- 결과: kcg.detection_models 에 'gear_violation_g01_g06' 1 행 INSERT
|
||||||
|
-- kcg.detection_model_versions 에 v1.0.0 status=DRAFT role=NULL 1 행 INSERT
|
||||||
|
--
|
||||||
|
-- 동치성 보장: params JSONB 는 prediction/algorithms/gear_violation.py
|
||||||
|
-- GEAR_VIOLATION_DEFAULT_PARAMS 와 1:1 일치 (Python 상수가 SSOT).
|
||||||
|
--
|
||||||
|
-- 롤백:
|
||||||
|
-- DELETE FROM kcg.detection_model_versions
|
||||||
|
-- WHERE model_id = 'gear_violation_g01_g06' AND version = '1.0.0';
|
||||||
|
-- DELETE FROM kcg.detection_models WHERE model_id = 'gear_violation_g01_g06';
|
||||||
|
|
||||||
|
INSERT INTO kcg.detection_models (
|
||||||
|
model_id, display_name, tier, category,
|
||||||
|
description, entry_module, entry_callable, is_enabled
|
||||||
|
) VALUES (
|
||||||
|
'gear_violation_g01_g06',
|
||||||
|
'어구 위반 G-01~G-06 종합 (DAR-03)',
|
||||||
|
4,
|
||||||
|
'GEAR',
|
||||||
|
'DAR-03 규격 G-01 허가수역 외 조업 + G-02 금어기 + G-03 미등록 어구 + G-04 MMSI 조작 + G-05 어구 drift + G-06 쌍끌이 공조 판정 통합. 각 G-code 점수·허용 어구 매핑·signal cycling 임계는 params 에서 조정.',
|
||||||
|
'models_core.registered.gear_violation_model',
|
||||||
|
'GearViolationModel',
|
||||||
|
TRUE
|
||||||
|
) ON CONFLICT (model_id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO kcg.detection_model_versions (
|
||||||
|
model_id, version, status, role, params, notes
|
||||||
|
) VALUES (
|
||||||
|
'gear_violation_g01_g06',
|
||||||
|
'1.0.0',
|
||||||
|
'DRAFT',
|
||||||
|
NULL,
|
||||||
|
$json${
|
||||||
|
"scores": {
|
||||||
|
"G01_zone_violation": 15,
|
||||||
|
"G02_closed_season": 18,
|
||||||
|
"G03_unregistered_gear": 12,
|
||||||
|
"G04_signal_cycling": 10,
|
||||||
|
"G05_gear_drift": 5,
|
||||||
|
"G06_pair_trawl": 20
|
||||||
|
},
|
||||||
|
"signal_cycling": {"gap_min": 30, "min_count": 2},
|
||||||
|
"gear_drift_threshold_nm": 0.270,
|
||||||
|
"fixed_gear_types": ["FPO", "FYK", "GN", "GND", "GNS", "TRAP"],
|
||||||
|
"fishery_code_allowed_gear": {
|
||||||
|
"PT": ["PT", "PT-S", "TRAWL"],
|
||||||
|
"PT-S": ["PT", "PT-S", "TRAWL"],
|
||||||
|
"GN": ["GILLNET", "GN", "GND", "GNS"],
|
||||||
|
"PS": ["PS", "PURSE"],
|
||||||
|
"OT": ["OT", "TRAWL"],
|
||||||
|
"FC": []
|
||||||
|
}
|
||||||
|
}$json$::jsonb,
|
||||||
|
'Phase 2 PoC #2 seed. Python GEAR_VIOLATION_DEFAULT_PARAMS 와 1:1 일치.'
|
||||||
|
) ON CONFLICT (model_id, version) DO NOTHING;
|
||||||
|
|
||||||
|
-- 확인
|
||||||
|
SELECT model_id, is_enabled,
|
||||||
|
(SELECT count(*) FROM kcg.detection_model_versions v WHERE v.model_id = m.model_id) AS versions
|
||||||
|
FROM kcg.detection_models m
|
||||||
|
WHERE model_id = 'gear_violation_g01_g06';
|
||||||
65
prediction/models_core/seeds/v1_pair_trawl.sql
Normal file
65
prediction/models_core/seeds/v1_pair_trawl.sql
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
-- Phase 2 PoC #5 — pair_trawl_tier 모델 seed (카탈로그 + 관찰 전용)
|
||||||
|
--
|
||||||
|
-- STRONG/PROBABLE/SUSPECT tier 임계 + candidate scan 파라미터 노출.
|
||||||
|
-- 런타임 override 는 후속 리팩토링 PR.
|
||||||
|
|
||||||
|
INSERT INTO kcg.detection_models (
|
||||||
|
model_id, display_name, tier, category,
|
||||||
|
description, entry_module, entry_callable, is_enabled
|
||||||
|
) VALUES (
|
||||||
|
'pair_trawl_tier',
|
||||||
|
'쌍끌이 공조 tier (STRONG / PROBABLE / SUSPECT)',
|
||||||
|
4,
|
||||||
|
'GEAR',
|
||||||
|
'두 선박의 근접·속력·방향 동조 기반 쌍끌이(G-06) 판정. STRONG(스펙 100%) / PROBABLE(1h+ 동조) / SUSPECT(30m+ 약한 동조) 3 tier. 현 버전은 params 카탈로그 등록만.',
|
||||||
|
'models_core.registered.pair_trawl_model',
|
||||||
|
'PairTrawlModel',
|
||||||
|
TRUE
|
||||||
|
) ON CONFLICT (model_id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO kcg.detection_model_versions (
|
||||||
|
model_id, version, status, role, params, notes
|
||||||
|
) VALUES (
|
||||||
|
'pair_trawl_tier',
|
||||||
|
'1.0.0',
|
||||||
|
'DRAFT',
|
||||||
|
NULL,
|
||||||
|
$json${
|
||||||
|
"cycle_interval_min": 5,
|
||||||
|
"strong": {
|
||||||
|
"proximity_nm": 0.27,
|
||||||
|
"sog_delta_max": 0.5,
|
||||||
|
"cog_delta_max": 10.0,
|
||||||
|
"sog_min": 2.0,
|
||||||
|
"sog_max": 4.0,
|
||||||
|
"min_sync_cycles": 24,
|
||||||
|
"simultaneous_gap_min": 30
|
||||||
|
},
|
||||||
|
"probable": {
|
||||||
|
"min_block_cycles": 12,
|
||||||
|
"min_sync_ratio": 0.6,
|
||||||
|
"proximity_nm": 0.43,
|
||||||
|
"sog_delta_max": 1.0,
|
||||||
|
"cog_delta_max": 20.0,
|
||||||
|
"sog_min": 1.5,
|
||||||
|
"sog_max": 5.0
|
||||||
|
},
|
||||||
|
"suspect": {
|
||||||
|
"min_block_cycles": 6,
|
||||||
|
"min_sync_ratio": 0.3
|
||||||
|
},
|
||||||
|
"candidate_scan": {
|
||||||
|
"cell_size_deg": 0.01,
|
||||||
|
"proximity_factor": 2.0,
|
||||||
|
"sog_min": 1.5,
|
||||||
|
"sog_max": 5.0
|
||||||
|
}
|
||||||
|
}$json$::jsonb,
|
||||||
|
'Phase 2 PoC #5 seed. Python PAIR_TRAWL_DEFAULT_PARAMS 와 1:1 일치.'
|
||||||
|
) ON CONFLICT (model_id, version) DO NOTHING;
|
||||||
|
|
||||||
|
-- 확인
|
||||||
|
SELECT model_id, is_enabled,
|
||||||
|
(SELECT count(*) FROM kcg.detection_model_versions v WHERE v.model_id = m.model_id) AS versions
|
||||||
|
FROM kcg.detection_models m
|
||||||
|
WHERE model_id = 'pair_trawl_tier';
|
||||||
47
prediction/models_core/seeds/v1_phase2_all.sql
Normal file
47
prediction/models_core/seeds/v1_phase2_all.sql
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
-- Phase 2 PoC 5 모델 일괄 seed
|
||||||
|
--
|
||||||
|
-- 한 번의 트랜잭션으로 5 모델 × 각 1 버전(DRAFT) 을 카탈로그에 등록.
|
||||||
|
-- 호출자가 BEGIN/COMMIT 을 제공하거나 `psql -1` 래핑을 사용해야 한다.
|
||||||
|
--
|
||||||
|
-- 실행:
|
||||||
|
-- psql -v ON_ERROR_STOP=1 -1 \
|
||||||
|
-- -f prediction/models_core/seeds/v1_phase2_all.sql
|
||||||
|
--
|
||||||
|
-- dry-run:
|
||||||
|
-- psql -v ON_ERROR_STOP=1 <<'SQL'
|
||||||
|
-- BEGIN;
|
||||||
|
-- \i prediction/models_core/seeds/v1_phase2_all.sql
|
||||||
|
-- SELECT model_id, tier, (SELECT count(*) FROM kcg.detection_model_versions v
|
||||||
|
-- WHERE v.model_id=m.model_id) vers
|
||||||
|
-- FROM kcg.detection_models m ORDER BY tier, model_id;
|
||||||
|
-- ROLLBACK;
|
||||||
|
-- SQL
|
||||||
|
--
|
||||||
|
-- 개별 롤백: 각 v1_<model>.sql 하단 주석 참조.
|
||||||
|
--
|
||||||
|
-- 전체 롤백:
|
||||||
|
-- DELETE FROM kcg.detection_model_versions WHERE model_id IN (
|
||||||
|
-- 'dark_suspicion', 'gear_violation_g01_g06', 'transshipment_5stage',
|
||||||
|
-- 'risk_composite', 'pair_trawl_tier'
|
||||||
|
-- );
|
||||||
|
-- DELETE FROM kcg.detection_models WHERE model_id IN (
|
||||||
|
-- 'dark_suspicion', 'gear_violation_g01_g06', 'transshipment_5stage',
|
||||||
|
-- 'risk_composite', 'pair_trawl_tier'
|
||||||
|
-- );
|
||||||
|
|
||||||
|
\i prediction/models_core/seeds/v1_dark_suspicion.sql
|
||||||
|
\i prediction/models_core/seeds/v1_gear_violation.sql
|
||||||
|
\i prediction/models_core/seeds/v1_transshipment.sql
|
||||||
|
\i prediction/models_core/seeds/v1_risk_composite.sql
|
||||||
|
\i prediction/models_core/seeds/v1_pair_trawl.sql
|
||||||
|
|
||||||
|
-- 최종 확인
|
||||||
|
SELECT m.model_id, m.tier, m.category, m.is_enabled,
|
||||||
|
(SELECT count(*) FROM kcg.detection_model_versions v
|
||||||
|
WHERE v.model_id = m.model_id) AS versions
|
||||||
|
FROM kcg.detection_models m
|
||||||
|
WHERE m.model_id IN (
|
||||||
|
'dark_suspicion', 'gear_violation_g01_g06', 'transshipment_5stage',
|
||||||
|
'risk_composite', 'pair_trawl_tier'
|
||||||
|
)
|
||||||
|
ORDER BY m.tier, m.model_id;
|
||||||
79
prediction/models_core/seeds/v1_risk_composite.sql
Normal file
79
prediction/models_core/seeds/v1_risk_composite.sql
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
-- Phase 2 PoC #4 — risk_composite 모델 seed (카탈로그 등록 + 관찰 전용)
|
||||||
|
--
|
||||||
|
-- compute_lightweight_risk_score / compute_vessel_risk_score 가 inline 숫자를
|
||||||
|
-- 직접 쓰고 있어 이번 버전은 params 카탈로그·관찰만 등록. 런타임 override 는
|
||||||
|
-- 후속 리팩토링 PR 에서 활성화.
|
||||||
|
|
||||||
|
INSERT INTO kcg.detection_models (
|
||||||
|
model_id, display_name, tier, category,
|
||||||
|
description, entry_module, entry_callable, is_enabled
|
||||||
|
) VALUES (
|
||||||
|
'risk_composite',
|
||||||
|
'종합 위험도 (경량 + 파이프라인)',
|
||||||
|
3,
|
||||||
|
'META',
|
||||||
|
'파이프라인 미통과(경량) + 통과(정밀) 경로의 위험도 점수(0~100) + tier(CRITICAL/HIGH/MEDIUM/LOW) 산출. 수역·다크·스푸핑·허가·반복 축으로 가산. 현 버전은 params 카탈로그 등록만.',
|
||||||
|
'models_core.registered.risk_composite_model',
|
||||||
|
'RiskCompositeModel',
|
||||||
|
TRUE
|
||||||
|
) ON CONFLICT (model_id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO kcg.detection_model_versions (
|
||||||
|
model_id, version, status, role, params, notes
|
||||||
|
) VALUES (
|
||||||
|
'risk_composite',
|
||||||
|
'1.0.0',
|
||||||
|
'DRAFT',
|
||||||
|
NULL,
|
||||||
|
$json${
|
||||||
|
"tier_thresholds": {"critical": 70, "high": 50, "medium": 30},
|
||||||
|
"lightweight_weights": {
|
||||||
|
"territorial_sea": 40,
|
||||||
|
"contiguous_zone": 15,
|
||||||
|
"zone_unpermitted": 25,
|
||||||
|
"eez_lt12nm": 15,
|
||||||
|
"eez_lt24nm": 8,
|
||||||
|
"dark_suspicion_multiplier": 0.3,
|
||||||
|
"dark_gap_720_min": 25,
|
||||||
|
"dark_gap_180_min": 20,
|
||||||
|
"dark_gap_60_min": 15,
|
||||||
|
"dark_gap_30_min": 8,
|
||||||
|
"spoofing_gt07": 15,
|
||||||
|
"spoofing_gt05": 8,
|
||||||
|
"unpermitted_alone": 15,
|
||||||
|
"unpermitted_with_suspicion": 8,
|
||||||
|
"repeat_gte5": 10,
|
||||||
|
"repeat_gte2": 5
|
||||||
|
},
|
||||||
|
"pipeline_weights": {
|
||||||
|
"territorial_sea": 40,
|
||||||
|
"contiguous_zone": 10,
|
||||||
|
"zone_unpermitted": 25,
|
||||||
|
"territorial_fishing": 20,
|
||||||
|
"fishing_segments_any": 5,
|
||||||
|
"trawl_uturn": 10,
|
||||||
|
"teleportation": 20,
|
||||||
|
"speed_jumps_ge3": 10,
|
||||||
|
"speed_jumps_ge1": 5,
|
||||||
|
"critical_gaps_ge60": 15,
|
||||||
|
"any_gaps": 5,
|
||||||
|
"unpermitted": 20
|
||||||
|
},
|
||||||
|
"dark_suspicion_fallback_gap_min": {
|
||||||
|
"very_long_720": 720,
|
||||||
|
"long_180": 180,
|
||||||
|
"mid_60": 60,
|
||||||
|
"short_30": 30
|
||||||
|
},
|
||||||
|
"spoofing_thresholds": {"high_0.7": 0.7, "medium_0.5": 0.5},
|
||||||
|
"eez_proximity_nm": {"inner_12": 12, "outer_24": 24},
|
||||||
|
"repeat_thresholds": {"h24_high": 5, "h24_low": 2}
|
||||||
|
}$json$::jsonb,
|
||||||
|
'Phase 2 PoC #4 seed. Python RISK_COMPOSITE_DEFAULT_PARAMS 와 1:1 일치.'
|
||||||
|
) ON CONFLICT (model_id, version) DO NOTHING;
|
||||||
|
|
||||||
|
-- 확인
|
||||||
|
SELECT model_id, is_enabled,
|
||||||
|
(SELECT count(*) FROM kcg.detection_model_versions v WHERE v.model_id = m.model_id) AS versions
|
||||||
|
FROM kcg.detection_models m
|
||||||
|
WHERE model_id = 'risk_composite';
|
||||||
51
prediction/models_core/seeds/v1_transshipment.sql
Normal file
51
prediction/models_core/seeds/v1_transshipment.sql
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
-- Phase 2 PoC #3 — transshipment_5stage 모델 seed (카탈로그 등록 + 관찰 전용)
|
||||||
|
--
|
||||||
|
-- 본 버전은 `params` 를 DB 에 노출하지만 런타임 override 는 아직 반영하지 않는다.
|
||||||
|
-- 내부 헬퍼 함수들(_is_proximity / _is_approach / _evict_expired)이 모듈 레벨 상수를
|
||||||
|
-- 직접 참조하므로, 후속 리팩토링 PR 에서 params 전파를 완성하면 런타임 값 교체가
|
||||||
|
-- 가능해진다. 카탈로그·관찰만으로 Phase 2 PoC 의 "모델 단위 분리" 가치는 확보.
|
||||||
|
--
|
||||||
|
-- 실행 방법 + 롤백은 seeds/README.md 참조.
|
||||||
|
|
||||||
|
INSERT INTO kcg.detection_models (
|
||||||
|
model_id, display_name, tier, category,
|
||||||
|
description, entry_module, entry_callable, is_enabled
|
||||||
|
) VALUES (
|
||||||
|
'transshipment_5stage',
|
||||||
|
'환적 의심 5단계 필터 (이종 쌍 → 감시영역 → APPROACH → RENDEZVOUS → 점수)',
|
||||||
|
4,
|
||||||
|
'TRANSSHIP',
|
||||||
|
'어선 ↔ 운반선 이종 쌍을 감시영역 내에서만 추적해 APPROACH → RENDEZVOUS → DEPARTURE 패턴을 검증하고 점수 산출. 현 버전은 params 카탈로그 등록만, 런타임 override 는 후속 PR 에서.',
|
||||||
|
'models_core.registered.transshipment_model',
|
||||||
|
'TransshipmentModel',
|
||||||
|
TRUE
|
||||||
|
) ON CONFLICT (model_id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO kcg.detection_model_versions (
|
||||||
|
model_id, version, status, role, params, notes
|
||||||
|
) VALUES (
|
||||||
|
'transshipment_5stage',
|
||||||
|
'1.0.0',
|
||||||
|
'DRAFT',
|
||||||
|
NULL,
|
||||||
|
$json${
|
||||||
|
"sog_threshold_kn": 2.0,
|
||||||
|
"proximity_deg": 0.002,
|
||||||
|
"approach_deg": 0.01,
|
||||||
|
"rendezvous_min": 90,
|
||||||
|
"pair_expiry_min": 240,
|
||||||
|
"gap_tolerance_cycles": 3,
|
||||||
|
"fishing_kinds": ["000020"],
|
||||||
|
"carrier_kinds": ["000023", "000024"],
|
||||||
|
"excluded_ship_ty": ["AtoN", "Anti Pollution", "Law Enforcement", "Medical Transport", "Passenger", "Pilot Boat", "Search And Rescue", "Tug"],
|
||||||
|
"carrier_hints": ["cargo", "tanker", "supply", "carrier", "reefer"],
|
||||||
|
"min_score": 50
|
||||||
|
}$json$::jsonb,
|
||||||
|
'Phase 2 PoC #3 seed. Python TRANSSHIPMENT_DEFAULT_PARAMS 와 1:1 일치. 현 버전은 카탈로그만, 런타임 override 는 후속 PR.'
|
||||||
|
) ON CONFLICT (model_id, version) DO NOTHING;
|
||||||
|
|
||||||
|
-- 확인
|
||||||
|
SELECT model_id, is_enabled,
|
||||||
|
(SELECT count(*) FROM kcg.detection_model_versions v WHERE v.model_id = m.model_id) AS versions
|
||||||
|
FROM kcg.detection_models m
|
||||||
|
WHERE model_id = 'transshipment_5stage';
|
||||||
@ -74,6 +74,42 @@ def _fetch_dark_history(kcg_conn, mmsi_list: list[str]) -> dict[str, dict]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_detection_model_registry(cycle_started_at, results):
|
||||||
|
"""Phase 1-2 — ACTIVE 버전 인스턴스를 모두 돌려서 비교·관측 데이터를 남긴다.
|
||||||
|
|
||||||
|
신 경로는 기존 사이클 결과(`results`)를 대체하지 않는다. ctx.inputs 로
|
||||||
|
전달되어 각 모델이 **같은 입력에 대해** PRIMARY/SHADOW 결과를 내도록 한다.
|
||||||
|
|
||||||
|
Phase 2 에서 실제 모델 클래스가 추가되기 전까지는 ACTIVE 버전이 없어
|
||||||
|
사실상 no-op 에 가깝다. 구 경로와의 공존을 위해 항상 try/except 로 감싼다.
|
||||||
|
"""
|
||||||
|
from db import kcgdb
|
||||||
|
from models_core.base import ModelContext
|
||||||
|
from models_core.executor import DAGExecutor
|
||||||
|
from models_core.registry import get_registry
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
with kcgdb.get_conn() as conn:
|
||||||
|
try:
|
||||||
|
plan = registry.build_plan(conn)
|
||||||
|
except Exception:
|
||||||
|
logger.exception('detection model plan build failed — skipping registry stage')
|
||||||
|
return
|
||||||
|
|
||||||
|
if not plan.primaries and not plan.shadows:
|
||||||
|
logger.info('detection model registry: no ACTIVE versions — nothing to run')
|
||||||
|
return
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
|
inputs = [asdict(r) for r in (results or [])]
|
||||||
|
ctx = ModelContext(
|
||||||
|
cycle_started_at=cycle_started_at,
|
||||||
|
conn=conn,
|
||||||
|
inputs=inputs,
|
||||||
|
)
|
||||||
|
DAGExecutor(plan).run(ctx)
|
||||||
|
|
||||||
|
|
||||||
def get_last_run() -> dict:
|
def get_last_run() -> dict:
|
||||||
return _last_run.copy()
|
return _last_run.copy()
|
||||||
|
|
||||||
@ -790,6 +826,22 @@ def run_analysis_cycle():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception('failed to cache analysis context for chat: %s', e)
|
logger.exception('failed to cache analysis context for chat: %s', e)
|
||||||
|
|
||||||
|
# 10. Detection Model Registry (Phase 1-2)
|
||||||
|
# PREDICTION_USE_MODEL_REGISTRY=1 일 때만 신 경로 실행. 기본은 구 경로만.
|
||||||
|
# 이 분기는 기존 사이클 결과를 건드리지 않고, ACTIVE 버전들의 결과를
|
||||||
|
# detection_model_run_outputs / detection_model_metrics 에 기록한다.
|
||||||
|
try:
|
||||||
|
from models_core.feature_flag import use_model_registry
|
||||||
|
if use_model_registry():
|
||||||
|
run_stage(
|
||||||
|
'detection_model_registry',
|
||||||
|
_run_detection_model_registry,
|
||||||
|
cycle_started_at=datetime.fromisoformat(_last_run['timestamp']),
|
||||||
|
results=results,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('detection model registry stage setup failed: %s', e)
|
||||||
|
|
||||||
elapsed = round(time.time() - start, 2)
|
elapsed = round(time.time() - start, 2)
|
||||||
_last_run['duration_sec'] = elapsed
|
_last_run['duration_sec'] = elapsed
|
||||||
_last_run['vessel_count'] = len(results)
|
_last_run['vessel_count'] = len(results)
|
||||||
|
|||||||
@ -55,6 +55,22 @@ FROM kcg.vessel_analysis_results
|
|||||||
WHERE analyzed_at > now() - interval '5 minutes';
|
WHERE analyzed_at > now() - interval '5 minutes';
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- 1b. SPOOFING signal health (silent-vs-fault 구분) ---"
|
||||||
|
# gt0 > 0 인데 gt0.5 = 0 이면 "알고리즘 동작 + threshold 미돌파" (정상),
|
||||||
|
# gt0 = 0 이면 "알고리즘 자체가 계산을 못 하고 있음" (silent fault) → 로그 추적 필요.
|
||||||
|
$PSQL_TABLE << 'SQL'
|
||||||
|
SELECT count(*) total,
|
||||||
|
count(*) FILTER (WHERE spoofing_score > 0) gt0,
|
||||||
|
count(*) FILTER (WHERE spoofing_score > 0.3) gt03,
|
||||||
|
count(*) FILTER (WHERE spoofing_score > 0.5) gt05,
|
||||||
|
count(*) FILTER (WHERE spoofing_score > 0.7) gt07,
|
||||||
|
round(avg(spoofing_score)::numeric, 4) avg_score,
|
||||||
|
round(max(spoofing_score)::numeric, 4) max_score
|
||||||
|
FROM kcg.vessel_analysis_results
|
||||||
|
WHERE analyzed_at > now() - interval '5 minutes';
|
||||||
|
SQL
|
||||||
|
|
||||||
#===================================================================
|
#===================================================================
|
||||||
# PART 2: 다크베셀 심층 진단
|
# PART 2: 다크베셀 심층 진단
|
||||||
#===================================================================
|
#===================================================================
|
||||||
@ -346,15 +362,64 @@ FROM kcg.prediction_kpi_realtime ORDER BY kpi_key;
|
|||||||
SQL
|
SQL
|
||||||
|
|
||||||
#===================================================================
|
#===================================================================
|
||||||
# PART 7: 사이클 로그 + 에러
|
# PART 6.5: V030 + V034 관찰 (원시 테이블)
|
||||||
|
#===================================================================
|
||||||
|
echo ""
|
||||||
|
echo "================================================================="
|
||||||
|
echo "PART 6.5: V030 gear_identity_collisions + V034 detection_model_*"
|
||||||
|
echo "================================================================="
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- 6.5-1. gear_identity_collisions severity x status (1h) ---"
|
||||||
|
$PSQL_TABLE << 'SQL'
|
||||||
|
SELECT severity, status, count(*) cnt, max(last_seen_at) last_seen
|
||||||
|
FROM kcg.gear_identity_collisions
|
||||||
|
WHERE last_seen_at > now() - interval '1 hour'
|
||||||
|
GROUP BY severity, status ORDER BY cnt DESC;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- 6.5-2. detection_models + 버전 상태 (Phase 1-2 이후 활성) ---"
|
||||||
|
$PSQL_TABLE << 'SQL'
|
||||||
|
SELECT count(*) AS catalog,
|
||||||
|
count(*) FILTER (WHERE is_enabled) AS enabled
|
||||||
|
FROM kcg.detection_models;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
$PSQL_TABLE << 'SQL'
|
||||||
|
SELECT status, coalesce(role,'(null)') role, count(*) cnt
|
||||||
|
FROM kcg.detection_model_versions
|
||||||
|
GROUP BY status, role ORDER BY status, role;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- 6.5-3. detection_model_run_outputs 5분 적재 (feature flag ON 시 증가) ---"
|
||||||
|
$PSQL_TABLE << 'SQL'
|
||||||
|
SELECT model_id, role, count(*) rows
|
||||||
|
FROM kcg.detection_model_run_outputs
|
||||||
|
WHERE cycle_started_at > now() - interval '5 minutes'
|
||||||
|
GROUP BY model_id, role ORDER BY rows DESC LIMIT 10;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
#===================================================================
|
||||||
|
# PART 7: 사이클 로그 + 에러 + stage timing
|
||||||
#===================================================================
|
#===================================================================
|
||||||
echo ""
|
echo ""
|
||||||
echo "================================================================="
|
echo "================================================================="
|
||||||
echo "PART 7: 사이클 로그 (최근 6분)"
|
echo "PART 7: 사이클 로그 (최근 6분)"
|
||||||
echo "================================================================="
|
echo "================================================================="
|
||||||
|
# stage_runner (Phase 0-1) + DAGExecutor (Phase 1-2) 로그 추가
|
||||||
journalctl -u kcg-ai-prediction --since '6 minutes ago' --no-pager 2>/dev/null | \
|
journalctl -u kcg-ai-prediction --since '6 minutes ago' --no-pager 2>/dev/null | \
|
||||||
grep -E 'analysis cycle:|lightweight|pipeline dark:|event_generator:|pair_trawl|gear_violation|GEAR_ILLEGAL|ERROR|Traceback' | \
|
grep -E 'analysis cycle:|lightweight|pipeline dark:|event_generator:|pair_trawl|gear_violation|GEAR_ILLEGAL|stage [a-z_]+ (ok|failed)|DAGExecutor done|detection model registry|ERROR|Traceback' | \
|
||||||
tail -20
|
tail -40
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- 7-1. STAGE TIMING (소요시간 상위 + 실패) ---"
|
||||||
|
journalctl -u kcg-ai-prediction --since '6 minutes ago' --no-pager 2>/dev/null | \
|
||||||
|
grep -oE 'stage [a-z_@.[:blank:][:digit:].-]+ (ok in [0-9.]+s|failed)' | \
|
||||||
|
awk '/failed/ {print "FAIL " $0; next}
|
||||||
|
/ok in/ {n=split($0,a," "); sec=a[n]; sub(/s$/,"",sec); printf "%8.2fs %s\n", sec, $0}' | \
|
||||||
|
sort -rn | awk 'NR<=8 || /^FAIL/' | head -20
|
||||||
|
|
||||||
#===================================================================
|
#===================================================================
|
||||||
# PART 7.5: 한중어업협정 레지스트리 매칭 (V029)
|
# PART 7.5: 한중어업협정 레지스트리 매칭 (V029)
|
||||||
|
|||||||
@ -37,6 +37,21 @@ SELECT count(*) total,
|
|||||||
FROM kcg.vessel_analysis_results
|
FROM kcg.vessel_analysis_results
|
||||||
WHERE analyzed_at > now() - interval '1 hour';
|
WHERE analyzed_at > now() - interval '1 hour';
|
||||||
|
|
||||||
|
\echo
|
||||||
|
\echo === 1a. SPOOFING signal health (silent-vs-fault 구분) ===
|
||||||
|
-- spoof_hi=0 이 "고장"인지 "신호 없음"인지 구분하려면 gt0 / gt03 / gt05 / max 를 모두 본다.
|
||||||
|
-- gt0 가 0 이면 파이프라인이 spoofing_score 를 아예 계산하지 못하고 있다는 신호 (원인 추적 필요).
|
||||||
|
-- gt0>0 인데 gt05=0 이면 알고리즘은 동작 중이나 threshold 돌파 대상이 없다 (정상일 수 있음).
|
||||||
|
SELECT count(*) total,
|
||||||
|
count(*) FILTER (WHERE spoofing_score > 0) gt0,
|
||||||
|
count(*) FILTER (WHERE spoofing_score > 0.3) gt03,
|
||||||
|
count(*) FILTER (WHERE spoofing_score > 0.5) gt05,
|
||||||
|
count(*) FILTER (WHERE spoofing_score > 0.7) gt07,
|
||||||
|
round(avg(spoofing_score)::numeric, 4) avg_score,
|
||||||
|
round(max(spoofing_score)::numeric, 4) max_score
|
||||||
|
FROM kcg.vessel_analysis_results
|
||||||
|
WHERE analyzed_at > now() - interval '1 hour';
|
||||||
|
|
||||||
\echo
|
\echo
|
||||||
\echo === 2. ZONE x DARK x GEAR_VIOLATION distribution ===
|
\echo === 2. ZONE x DARK x GEAR_VIOLATION distribution ===
|
||||||
SELECT zone_code,
|
SELECT zone_code,
|
||||||
@ -369,18 +384,101 @@ SELECT date_trunc('hour', occurred_at AT TIME ZONE 'Asia/Seoul') hr,
|
|||||||
count(*) FILTER (WHERE category='EEZ_INTRUSION') eez,
|
count(*) FILTER (WHERE category='EEZ_INTRUSION') eez,
|
||||||
count(*) FILTER (WHERE category='GEAR_ILLEGAL') gear_illegal,
|
count(*) FILTER (WHERE category='GEAR_ILLEGAL') gear_illegal,
|
||||||
count(*) FILTER (WHERE category='HIGH_RISK_VESSEL') high_risk,
|
count(*) FILTER (WHERE category='HIGH_RISK_VESSEL') high_risk,
|
||||||
|
count(*) FILTER (WHERE category='GEAR_IDENTITY_COLLISION') gear_collide,
|
||||||
count(*) FILTER (WHERE level='CRITICAL') critical
|
count(*) FILTER (WHERE level='CRITICAL') critical
|
||||||
FROM kcg.prediction_events
|
FROM kcg.prediction_events
|
||||||
WHERE created_at > now() - interval '24 hours'
|
WHERE created_at > now() - interval '24 hours'
|
||||||
GROUP BY hr ORDER BY hr DESC LIMIT 25;
|
GROUP BY hr ORDER BY hr DESC LIMIT 25;
|
||||||
|
|
||||||
|
\echo
|
||||||
|
\echo ===================================================================
|
||||||
|
\echo === V030 GEAR_IDENTITY_COLLISIONS (원시 테이블 관찰)
|
||||||
|
\echo ===================================================================
|
||||||
|
\echo
|
||||||
|
\echo === V030-1. severity x status 분포 (24h) ===
|
||||||
|
SELECT severity, status, count(*) cnt,
|
||||||
|
max(last_seen_at) last_seen
|
||||||
|
FROM kcg.gear_identity_collisions
|
||||||
|
WHERE last_seen_at > now() - interval '24 hours'
|
||||||
|
GROUP BY severity, status ORDER BY cnt DESC;
|
||||||
|
|
||||||
|
\echo
|
||||||
|
\echo === V030-2. coexistence/swap 상위 20건 (24h) ===
|
||||||
|
SELECT name, mmsi_lo, mmsi_hi, severity, status,
|
||||||
|
coexistence_count coex, swap_count swap,
|
||||||
|
round(max_distance_km::numeric, 1) max_km
|
||||||
|
FROM kcg.gear_identity_collisions
|
||||||
|
WHERE last_seen_at > now() - interval '24 hours'
|
||||||
|
ORDER BY (coexistence_count + swap_count * 5) DESC LIMIT 20;
|
||||||
|
|
||||||
|
\echo
|
||||||
|
\echo ===================================================================
|
||||||
|
\echo === V034 DETECTION_MODEL REGISTRY (Phase 1-2)
|
||||||
|
\echo ===================================================================
|
||||||
|
\echo
|
||||||
|
\echo === V034-1. model catalog + enabled 여부 ===
|
||||||
|
SELECT count(*) catalog_total,
|
||||||
|
count(*) FILTER (WHERE is_enabled) enabled
|
||||||
|
FROM kcg.detection_models;
|
||||||
|
|
||||||
|
\echo
|
||||||
|
\echo === V034-2. version 상태 x role 분포 ===
|
||||||
|
SELECT status, coalesce(role,'(null)') role, count(*) cnt
|
||||||
|
FROM kcg.detection_model_versions
|
||||||
|
GROUP BY status, role ORDER BY status, role;
|
||||||
|
|
||||||
|
\echo
|
||||||
|
\echo === V034-3. detection_model_run_outputs 1h 적재 현황 (feature flag ON 시 증가) ===
|
||||||
|
SELECT model_id, role, count(*) rows,
|
||||||
|
min(cycle_started_at) oldest, max(cycle_started_at) newest
|
||||||
|
FROM kcg.detection_model_run_outputs
|
||||||
|
WHERE cycle_started_at > now() - interval '1 hour'
|
||||||
|
GROUP BY model_id, role ORDER BY rows DESC;
|
||||||
|
|
||||||
|
\echo
|
||||||
|
\echo === V034-4. detection_model_metrics 최신 5 모델 평균 소요 ===
|
||||||
|
SELECT model_id, role,
|
||||||
|
round(avg(metric_value) FILTER (WHERE metric_key='cycle_duration_ms')::numeric, 1) avg_ms,
|
||||||
|
round(avg(metric_value) FILTER (WHERE metric_key='output_count')::numeric, 1) avg_out
|
||||||
|
FROM kcg.detection_model_metrics
|
||||||
|
WHERE cycle_started_at > now() - interval '1 hour'
|
||||||
|
GROUP BY model_id, role ORDER BY model_id, role;
|
||||||
|
|
||||||
|
\echo
|
||||||
|
\echo === C1. stats_hourly vs raw events 카테고리 drift (event_generator silent drop 감시) ===
|
||||||
|
-- raw prediction_events 에는 있지만 stats_hourly.by_category 에는 없는 카테고리 (반대도 표시)
|
||||||
|
WITH recent_events AS (
|
||||||
|
SELECT DISTINCT category FROM kcg.prediction_events
|
||||||
|
WHERE created_at > now() - interval '2 hours'
|
||||||
|
),
|
||||||
|
stats_cats AS (
|
||||||
|
SELECT DISTINCT jsonb_object_keys(by_category) AS category
|
||||||
|
FROM kcg.prediction_stats_hourly
|
||||||
|
WHERE stat_hour > now() - interval '2 hours'
|
||||||
|
)
|
||||||
|
SELECT 'only_in_events' gap, category FROM recent_events
|
||||||
|
WHERE category NOT IN (SELECT category FROM stats_cats)
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'only_in_stats', category FROM stats_cats
|
||||||
|
WHERE category NOT IN (SELECT category FROM recent_events);
|
||||||
|
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== 13. CYCLE LOG (last 65 min) ==="
|
echo "=== 13. CYCLE LOG (last 65 min) ==="
|
||||||
|
# stage_runner, DAGExecutor, detection_model_registry, Traceback 까지 함께 추적
|
||||||
journalctl -u kcg-ai-prediction --since '65 minutes ago' --no-pager 2>/dev/null | \
|
journalctl -u kcg-ai-prediction --since '65 minutes ago' --no-pager 2>/dev/null | \
|
||||||
grep -E 'lightweight|event_generator:|stats_aggregator hourly|kpi_writer:|analysis cycle:|pair_trawl|gear_violation|GEAR_ILLEGAL|ERROR|Traceback' | \
|
grep -E 'lightweight|event_generator:|stats_aggregator hourly|kpi_writer:|analysis cycle:|pair_trawl|gear_violation|GEAR_ILLEGAL|stage [a-z_]+ (ok|failed)|DAGExecutor done|detection model registry|ERROR|Traceback' | \
|
||||||
tail -60
|
tail -80
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 14. STAGE TIMING (last 65 min, 소요시간 상위 10 + 실패 전체) ==="
|
||||||
|
# stage ok in X.XXs / stage failed after 를 수집하여 실패+장시간 스테이지 식별
|
||||||
|
journalctl -u kcg-ai-prediction --since '65 minutes ago' --no-pager 2>/dev/null | \
|
||||||
|
grep -oE 'stage [a-z_@.[:blank:][:digit:].-]+ (ok in [0-9.]+s|failed)' | \
|
||||||
|
awk '/failed/ {print "FAIL " $0; next}
|
||||||
|
/ok in/ {n=split($0,a," "); sec=a[n]; sub(/s$/,"",sec); printf "%8.2fs %s\n", sec, $0}' | \
|
||||||
|
sort -rn | awk 'NR<=10 || /^FAIL/' | head -40
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== END ==="
|
echo "=== END ==="
|
||||||
|
|||||||
159
prediction/tests/test_dark_suspicion_params.py
Normal file
159
prediction/tests/test_dark_suspicion_params.py
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
"""Phase 2 PoC #1 — dark_suspicion params 외부화 동치성 테스트.
|
||||||
|
|
||||||
|
이 파일은 pandas 미설치 환경에서도 실행 가능하도록 구성한다.
|
||||||
|
`_merge_default_params` 와 DEFAULT_PARAMS 상수 자체만 단독 검증.
|
||||||
|
|
||||||
|
`compute_dark_suspicion` 전체 E2E 는 pandas 가 설치된 prediction 환경에서
|
||||||
|
수동으로 한 사이클 실행하여 신·구 diff=0 을 확인한다 (seed SQL 안내 참조).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
# pandas 미설치 환경 우회 — algorithms.dark_vessel 이 pandas 를 top-level import
|
||||||
|
# 하므로, 그 import 를 stub 으로 대체해 DEFAULT_PARAMS 와 _merge_default_params 만
|
||||||
|
# 추출한다.
|
||||||
|
if 'pandas' not in sys.modules:
|
||||||
|
pd_stub = types.ModuleType('pandas')
|
||||||
|
pd_stub.DataFrame = type('DataFrame', (), {}) # annotation 용 dummy
|
||||||
|
pd_stub.Timestamp = type('Timestamp', (), {})
|
||||||
|
sys.modules['pandas'] = pd_stub
|
||||||
|
|
||||||
|
# pydantic_settings stub (다른 테스트와 동일 관용)
|
||||||
|
if 'pydantic_settings' not in sys.modules:
|
||||||
|
stub = types.ModuleType('pydantic_settings')
|
||||||
|
|
||||||
|
class _S:
|
||||||
|
def __init__(self, **kw):
|
||||||
|
for name, value in self.__class__.__dict__.items():
|
||||||
|
if name.isupper():
|
||||||
|
setattr(self, name, kw.get(name, value))
|
||||||
|
|
||||||
|
stub.BaseSettings = _S
|
||||||
|
sys.modules['pydantic_settings'] = stub
|
||||||
|
|
||||||
|
# algorithms.location 도 top-level 의 haversine_nm import 가 있으므로 stub
|
||||||
|
if 'algorithms' not in sys.modules:
|
||||||
|
pkg = types.ModuleType('algorithms')
|
||||||
|
pkg.__path__ = [os.path.join(os.path.dirname(__file__), '..', 'algorithms')]
|
||||||
|
sys.modules['algorithms'] = pkg
|
||||||
|
|
||||||
|
if 'algorithms.location' not in sys.modules:
|
||||||
|
loc = types.ModuleType('algorithms.location')
|
||||||
|
loc.haversine_nm = lambda a, b, c, d: 0.0 # pragma: no cover
|
||||||
|
sys.modules['algorithms.location'] = loc
|
||||||
|
|
||||||
|
# 이제 dark_vessel 의 DEFAULT_PARAMS 와 _merge_default_params 만 조용히 import
|
||||||
|
dv = importlib.import_module('algorithms.dark_vessel')
|
||||||
|
|
||||||
|
|
||||||
|
class DarkSuspicionParamsTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_default_params_shape(self):
|
||||||
|
"""DEFAULT_PARAMS 는 11개 패턴 + tier_thresholds + sog_thresholds 를 포함한다."""
|
||||||
|
p = dv.DARK_SUSPICION_DEFAULT_PARAMS
|
||||||
|
self.assertIn('weights', p)
|
||||||
|
self.assertIn('tier_thresholds', p)
|
||||||
|
self.assertEqual(p['tier_thresholds'], {'critical': 70, 'high': 50, 'watch': 30})
|
||||||
|
# 11 패턴 기본 가중치 키
|
||||||
|
weights = p['weights']
|
||||||
|
for key in [
|
||||||
|
'P1_moving_off', 'P1_slow_moving_off',
|
||||||
|
'P2_sensitive_zone', 'P2_special_zone',
|
||||||
|
'P3_repeat_high', 'P3_repeat_low', 'P3_recent_dark',
|
||||||
|
'P4_distance_anomaly',
|
||||||
|
'P5_daytime_fishing_off',
|
||||||
|
'P6_teleport_before_gap',
|
||||||
|
'P7_unpermitted',
|
||||||
|
'P8_very_long_gap', 'P8_long_gap',
|
||||||
|
'P9_fishing_vessel_dark', 'P9_cargo_natural_gap',
|
||||||
|
'P10_underway_deliberate', 'P10_anchored_natural',
|
||||||
|
'P11_heading_cog_mismatch',
|
||||||
|
'out_of_coverage',
|
||||||
|
]:
|
||||||
|
self.assertIn(key, weights, f'weights.{key} missing')
|
||||||
|
|
||||||
|
def test_merge_none_returns_default_reference(self):
|
||||||
|
"""params=None 이면 DEFAULT 그대로 사용 (Phase 2 이전과 동일 동작)."""
|
||||||
|
self.assertIs(dv._merge_default_params(None), dv.DARK_SUSPICION_DEFAULT_PARAMS)
|
||||||
|
|
||||||
|
def test_merge_empty_dict_returns_default_equivalent(self):
|
||||||
|
"""params={} 면 DEFAULT 와 key-level 완전 동일."""
|
||||||
|
merged = dv._merge_default_params({})
|
||||||
|
self.assertEqual(merged, dv.DARK_SUSPICION_DEFAULT_PARAMS)
|
||||||
|
|
||||||
|
def test_merge_override_replaces_only_given_keys(self):
|
||||||
|
"""override 는 해당 key 만 교체, 나머지는 DEFAULT 유지."""
|
||||||
|
override = {'tier_thresholds': {'critical': 80}}
|
||||||
|
merged = dv._merge_default_params(override)
|
||||||
|
# critical 만 교체됨
|
||||||
|
self.assertEqual(merged['tier_thresholds']['critical'], 80)
|
||||||
|
# high/watch 는 DEFAULT 유지
|
||||||
|
self.assertEqual(merged['tier_thresholds']['high'], 50)
|
||||||
|
self.assertEqual(merged['tier_thresholds']['watch'], 30)
|
||||||
|
# weights 같은 다른 최상위 키는 DEFAULT 유지
|
||||||
|
self.assertEqual(
|
||||||
|
merged['weights']['P1_moving_off'],
|
||||||
|
dv.DARK_SUSPICION_DEFAULT_PARAMS['weights']['P1_moving_off'],
|
||||||
|
)
|
||||||
|
# override 가 DEFAULT 를 변조하지 않는다 (불변성)
|
||||||
|
self.assertEqual(
|
||||||
|
dv.DARK_SUSPICION_DEFAULT_PARAMS['tier_thresholds']['critical'], 70,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_seed_sql_values_match_python_default(self):
|
||||||
|
"""seed SQL 의 params JSONB 가 Python DEFAULT 와 1:1 일치하는지 정적 검증."""
|
||||||
|
seed_path = os.path.join(
|
||||||
|
os.path.dirname(__file__), '..',
|
||||||
|
'models_core', 'seeds', 'v1_dark_suspicion.sql',
|
||||||
|
)
|
||||||
|
with open(seed_path, 'r', encoding='utf-8') as f:
|
||||||
|
sql = f.read()
|
||||||
|
|
||||||
|
# $json$...$json$ 블록에서 JSON 추출
|
||||||
|
start = sql.index('$json$') + len('$json$')
|
||||||
|
end = sql.index('$json$', start)
|
||||||
|
raw = sql[start:end].strip()
|
||||||
|
params = json.loads(raw)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
params['tier_thresholds'],
|
||||||
|
dv.DARK_SUSPICION_DEFAULT_PARAMS['tier_thresholds'],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
params['weights'],
|
||||||
|
dv.DARK_SUSPICION_DEFAULT_PARAMS['weights'],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
params['sog_thresholds'],
|
||||||
|
dv.DARK_SUSPICION_DEFAULT_PARAMS['sog_thresholds'],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
params['repeat_thresholds'],
|
||||||
|
dv.DARK_SUSPICION_DEFAULT_PARAMS['repeat_thresholds'],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
params['gap_min_thresholds'],
|
||||||
|
dv.DARK_SUSPICION_DEFAULT_PARAMS['gap_min_thresholds'],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
params['heading_cog_mismatch_deg'],
|
||||||
|
dv.DARK_SUSPICION_DEFAULT_PARAMS['heading_cog_mismatch_deg'],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
params['p4_distance_multiplier'],
|
||||||
|
dv.DARK_SUSPICION_DEFAULT_PARAMS['p4_distance_multiplier'],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
list(params['p5_daytime_range']),
|
||||||
|
list(dv.DARK_SUSPICION_DEFAULT_PARAMS['p5_daytime_range']),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
125
prediction/tests/test_gear_violation_params.py
Normal file
125
prediction/tests/test_gear_violation_params.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"""Phase 2 PoC #2 — gear_violation_g01_g06 params 외부화 동치성 테스트.
|
||||||
|
|
||||||
|
pandas 미설치 환경을 우회하기 위해 dark_suspicion 테스트와 동일한 stub 패턴 사용.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
# pandas stub (annotation 용)
|
||||||
|
if 'pandas' not in sys.modules:
|
||||||
|
pd_stub = types.ModuleType('pandas')
|
||||||
|
pd_stub.DataFrame = type('DataFrame', (), {})
|
||||||
|
pd_stub.Timestamp = type('Timestamp', (), {})
|
||||||
|
sys.modules['pandas'] = pd_stub
|
||||||
|
|
||||||
|
if 'pydantic_settings' not in sys.modules:
|
||||||
|
stub = types.ModuleType('pydantic_settings')
|
||||||
|
|
||||||
|
class _S:
|
||||||
|
def __init__(self, **kw):
|
||||||
|
for name, value in self.__class__.__dict__.items():
|
||||||
|
if name.isupper():
|
||||||
|
setattr(self, name, kw.get(name, value))
|
||||||
|
|
||||||
|
stub.BaseSettings = _S
|
||||||
|
sys.modules['pydantic_settings'] = stub
|
||||||
|
|
||||||
|
if 'algorithms' not in sys.modules:
|
||||||
|
pkg = types.ModuleType('algorithms')
|
||||||
|
pkg.__path__ = [os.path.join(os.path.dirname(__file__), '..', 'algorithms')]
|
||||||
|
sys.modules['algorithms'] = pkg
|
||||||
|
|
||||||
|
gv = importlib.import_module('algorithms.gear_violation')
|
||||||
|
|
||||||
|
|
||||||
|
class GearViolationParamsTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_default_params_shape(self):
|
||||||
|
p = gv.GEAR_VIOLATION_DEFAULT_PARAMS
|
||||||
|
self.assertIn('scores', p)
|
||||||
|
self.assertIn('signal_cycling', p)
|
||||||
|
self.assertIn('gear_drift_threshold_nm', p)
|
||||||
|
self.assertIn('fixed_gear_types', p)
|
||||||
|
self.assertIn('fishery_code_allowed_gear', p)
|
||||||
|
# 6 G-codes 점수 키 전부 있는지
|
||||||
|
for k in ['G01_zone_violation', 'G02_closed_season', 'G03_unregistered_gear',
|
||||||
|
'G04_signal_cycling', 'G05_gear_drift', 'G06_pair_trawl']:
|
||||||
|
self.assertIn(k, p['scores'])
|
||||||
|
|
||||||
|
def test_default_values_match_module_constants(self):
|
||||||
|
"""DEFAULT_PARAMS 는 모듈 레벨 상수와 완전히 동일해야 한다 (SSOT 이중성 방지)."""
|
||||||
|
p = gv.GEAR_VIOLATION_DEFAULT_PARAMS
|
||||||
|
self.assertEqual(p['scores']['G01_zone_violation'], gv.G01_SCORE)
|
||||||
|
self.assertEqual(p['scores']['G02_closed_season'], gv.G02_SCORE)
|
||||||
|
self.assertEqual(p['scores']['G03_unregistered_gear'], gv.G03_SCORE)
|
||||||
|
self.assertEqual(p['scores']['G04_signal_cycling'], gv.G04_SCORE)
|
||||||
|
self.assertEqual(p['scores']['G05_gear_drift'], gv.G05_SCORE)
|
||||||
|
self.assertEqual(p['scores']['G06_pair_trawl'], gv.G06_SCORE)
|
||||||
|
self.assertEqual(p['signal_cycling']['gap_min'], gv.SIGNAL_CYCLING_GAP_MIN)
|
||||||
|
self.assertEqual(p['signal_cycling']['min_count'], gv.SIGNAL_CYCLING_MIN_COUNT)
|
||||||
|
self.assertAlmostEqual(p['gear_drift_threshold_nm'], gv.GEAR_DRIFT_THRESHOLD_NM)
|
||||||
|
self.assertEqual(set(p['fixed_gear_types']), gv.FIXED_GEAR_TYPES)
|
||||||
|
# fishery_code_allowed_gear: list ↔ set 변환 후 비교
|
||||||
|
for key, allowed in gv.FISHERY_CODE_ALLOWED_GEAR.items():
|
||||||
|
self.assertEqual(set(p['fishery_code_allowed_gear'][key]), allowed)
|
||||||
|
|
||||||
|
def test_merge_none_returns_default_reference(self):
|
||||||
|
self.assertIs(gv._merge_default_gv_params(None), gv.GEAR_VIOLATION_DEFAULT_PARAMS)
|
||||||
|
|
||||||
|
def test_merge_override_replaces_only_given_keys(self):
|
||||||
|
override = {'scores': {'G06_pair_trawl': 99}}
|
||||||
|
merged = gv._merge_default_gv_params(override)
|
||||||
|
self.assertEqual(merged['scores']['G06_pair_trawl'], 99)
|
||||||
|
# 다른 score 는 DEFAULT 유지
|
||||||
|
self.assertEqual(
|
||||||
|
merged['scores']['G01_zone_violation'],
|
||||||
|
gv.GEAR_VIOLATION_DEFAULT_PARAMS['scores']['G01_zone_violation'],
|
||||||
|
)
|
||||||
|
# fixed_gear_types 같은 top-level 키도 DEFAULT 유지
|
||||||
|
self.assertEqual(
|
||||||
|
merged['fixed_gear_types'],
|
||||||
|
gv.GEAR_VIOLATION_DEFAULT_PARAMS['fixed_gear_types'],
|
||||||
|
)
|
||||||
|
# DEFAULT 는 변조되지 않음
|
||||||
|
self.assertEqual(
|
||||||
|
gv.GEAR_VIOLATION_DEFAULT_PARAMS['scores']['G06_pair_trawl'], 20,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_seed_sql_values_match_python_default(self):
|
||||||
|
"""seed SQL JSONB ↔ Python DEFAULT 1:1 정적 검증."""
|
||||||
|
seed_path = os.path.join(
|
||||||
|
os.path.dirname(__file__), '..',
|
||||||
|
'models_core', 'seeds', 'v1_gear_violation.sql',
|
||||||
|
)
|
||||||
|
with open(seed_path, 'r', encoding='utf-8') as f:
|
||||||
|
sql = f.read()
|
||||||
|
|
||||||
|
start = sql.index('$json$') + len('$json$')
|
||||||
|
end = sql.index('$json$', start)
|
||||||
|
raw = sql[start:end].strip()
|
||||||
|
params = json.loads(raw)
|
||||||
|
|
||||||
|
expected = gv.GEAR_VIOLATION_DEFAULT_PARAMS
|
||||||
|
self.assertEqual(params['scores'], expected['scores'])
|
||||||
|
self.assertEqual(params['signal_cycling'], expected['signal_cycling'])
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
params['gear_drift_threshold_nm'], expected['gear_drift_threshold_nm']
|
||||||
|
)
|
||||||
|
# list 는 순서 무관하게 set 비교 (DB 에 저장 시 어떤 순서든 상관 없음)
|
||||||
|
self.assertEqual(set(params['fixed_gear_types']),
|
||||||
|
set(expected['fixed_gear_types']))
|
||||||
|
for code, allowed in expected['fishery_code_allowed_gear'].items():
|
||||||
|
self.assertEqual(
|
||||||
|
set(params['fishery_code_allowed_gear'][code]), set(allowed),
|
||||||
|
f'fishery_code_allowed_gear[{code}] mismatch',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
431
prediction/tests/test_models_core.py
Normal file
431
prediction/tests/test_models_core.py
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
"""models_core 기반 인프라 (Phase 1-2) 유닛테스트.
|
||||||
|
|
||||||
|
DB·서버 없이 순수 파이썬 레벨에서 다음을 검증:
|
||||||
|
- params_loader 캐시 TTL 동작
|
||||||
|
- ModelRegistry discover + 버전별 인스턴스화
|
||||||
|
- DAG topo 정렬 + 순환 검출
|
||||||
|
- DAGExecutor 의 오염 차단 불변식 (SHADOW 결과는 ctx.shared 에 들어가지 않음)
|
||||||
|
- PRIMARY 실패 시 후행 모델 skip
|
||||||
|
- SHADOW 전용(PRIMARY 없음) 모델 스킵 경고
|
||||||
|
- run_stage 와의 통합 — 예외가 한 버전에 격리되는지
|
||||||
|
|
||||||
|
실제 DB 상호작용은 Phase 1-3 testcontainers 기반에서 수행 (후속 커밋).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# pydantic_settings stub (기존 test_time_bucket 관용)
|
||||||
|
_stub = types.ModuleType('pydantic_settings')
|
||||||
|
|
||||||
|
|
||||||
|
class _StubBaseSettings:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
for name, value in self.__class__.__dict__.items():
|
||||||
|
if name.isupper():
|
||||||
|
setattr(self, name, kwargs.get(name, value))
|
||||||
|
|
||||||
|
|
||||||
|
_stub.BaseSettings = _StubBaseSettings
|
||||||
|
sys.modules.setdefault('pydantic_settings', _stub)
|
||||||
|
|
||||||
|
from models_core import base as mc_base
|
||||||
|
from models_core.base import (
|
||||||
|
BaseDetectionModel,
|
||||||
|
ModelContext,
|
||||||
|
ModelResult,
|
||||||
|
ROLE_PRIMARY,
|
||||||
|
ROLE_SHADOW,
|
||||||
|
make_input_ref,
|
||||||
|
)
|
||||||
|
from models_core import params_loader
|
||||||
|
from models_core.executor import DAGExecutor
|
||||||
|
from models_core.registry import DAGCycleError, ModelRegistry
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Fixture 클래스들
|
||||||
|
# ======================================================================
|
||||||
|
@dataclass
|
||||||
|
class _Call:
|
||||||
|
model_id: str
|
||||||
|
role: str
|
||||||
|
version_id: int
|
||||||
|
|
||||||
|
|
||||||
|
def _make_model_class(mid: str, depends: Optional[list] = None, *, raise_for_role: Optional[str] = None):
|
||||||
|
"""동적으로 BaseDetectionModel 서브클래스 생성."""
|
||||||
|
|
||||||
|
class _M(BaseDetectionModel):
|
||||||
|
model_id = mid
|
||||||
|
depends_on = list(depends or [])
|
||||||
|
|
||||||
|
def run(self, ctx: ModelContext) -> ModelResult:
|
||||||
|
if raise_for_role and self.role == raise_for_role:
|
||||||
|
raise RuntimeError(f'intentional failure in {mid}@{self.role}')
|
||||||
|
ctx.extras.setdefault('_calls', []).append(
|
||||||
|
_Call(self.model_id, self.role, self.version_id)
|
||||||
|
)
|
||||||
|
# input_ref 스키마를 PRIMARY/SHADOW 동일 유지
|
||||||
|
out_per = [
|
||||||
|
(make_input_ref('412000001'), {'score': 1.0 if self.role == ROLE_PRIMARY else 1.5}),
|
||||||
|
]
|
||||||
|
return ModelResult(
|
||||||
|
model_id=self.model_id,
|
||||||
|
version_id=self.version_id,
|
||||||
|
version_str=self.version_str,
|
||||||
|
role=self.role,
|
||||||
|
outputs_per_input=out_per,
|
||||||
|
metrics={'sentinel': float(self.version_id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
_M.__name__ = f'_M_{mid.replace(".", "_")}'
|
||||||
|
return _M
|
||||||
|
|
||||||
|
|
||||||
|
def _version_row(id_, model_id, role, version='1.0.0', params=None):
|
||||||
|
return params_loader.VersionRow(
|
||||||
|
id=id_, model_id=model_id, role=role, version=version, params=params or {}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# params_loader 캐시
|
||||||
|
# ======================================================================
|
||||||
|
class ParamsCacheTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
params_loader.invalidate_cache()
|
||||||
|
|
||||||
|
def test_invalidate_forces_reload(self):
|
||||||
|
calls = {'n': 0}
|
||||||
|
|
||||||
|
def fake_fetch(conn):
|
||||||
|
calls['n'] += 1
|
||||||
|
return [_version_row(1, 'a', ROLE_PRIMARY)]
|
||||||
|
|
||||||
|
orig = params_loader._fetch_active_versions
|
||||||
|
params_loader._fetch_active_versions = fake_fetch
|
||||||
|
try:
|
||||||
|
rows1 = params_loader.load_active_versions(conn=None)
|
||||||
|
rows2 = params_loader.load_active_versions(conn=None)
|
||||||
|
self.assertEqual(calls['n'], 1) # 두 번째는 캐시 HIT
|
||||||
|
self.assertEqual(len(rows1), 1)
|
||||||
|
self.assertEqual(len(rows2), 1)
|
||||||
|
|
||||||
|
params_loader.invalidate_cache()
|
||||||
|
params_loader.load_active_versions(conn=None)
|
||||||
|
self.assertEqual(calls['n'], 2)
|
||||||
|
finally:
|
||||||
|
params_loader._fetch_active_versions = orig
|
||||||
|
params_loader.invalidate_cache()
|
||||||
|
|
||||||
|
def test_force_reload_bypasses_ttl(self):
|
||||||
|
calls = {'n': 0}
|
||||||
|
|
||||||
|
def fake_fetch(conn):
|
||||||
|
calls['n'] += 1
|
||||||
|
return []
|
||||||
|
|
||||||
|
orig = params_loader._fetch_active_versions
|
||||||
|
params_loader._fetch_active_versions = fake_fetch
|
||||||
|
try:
|
||||||
|
params_loader.load_active_versions(conn=None)
|
||||||
|
params_loader.load_active_versions(conn=None, force_reload=True)
|
||||||
|
self.assertEqual(calls['n'], 2)
|
||||||
|
finally:
|
||||||
|
params_loader._fetch_active_versions = orig
|
||||||
|
params_loader.invalidate_cache()
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Registry topo 정렬 + DAG 검증
|
||||||
|
# ======================================================================
|
||||||
|
class RegistryTopoTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def _registry_with(self, *model_ids_with_deps):
|
||||||
|
"""[(model_id, [dep_ids]), ...] 에 맞춘 Registry 생성."""
|
||||||
|
reg = ModelRegistry()
|
||||||
|
for mid, deps in model_ids_with_deps:
|
||||||
|
reg.register_class(_make_model_class(mid, deps))
|
||||||
|
return reg
|
||||||
|
|
||||||
|
def test_topo_order_respects_dependencies(self):
|
||||||
|
reg = self._registry_with(
|
||||||
|
('a', []),
|
||||||
|
('b', ['a']),
|
||||||
|
('c', ['b']),
|
||||||
|
)
|
||||||
|
rows = [
|
||||||
|
_version_row(10, 'a', ROLE_PRIMARY),
|
||||||
|
_version_row(11, 'b', ROLE_PRIMARY),
|
||||||
|
_version_row(12, 'c', ROLE_PRIMARY),
|
||||||
|
]
|
||||||
|
plan = reg.build_plan_from_rows(rows)
|
||||||
|
self.assertEqual(plan.topo_order, ['a', 'b', 'c'])
|
||||||
|
|
||||||
|
def test_cycle_detection(self):
|
||||||
|
reg = self._registry_with(
|
||||||
|
('a', ['b']),
|
||||||
|
('b', ['a']),
|
||||||
|
)
|
||||||
|
rows = [
|
||||||
|
_version_row(1, 'a', ROLE_PRIMARY),
|
||||||
|
_version_row(2, 'b', ROLE_PRIMARY),
|
||||||
|
]
|
||||||
|
with self.assertRaises(DAGCycleError):
|
||||||
|
reg.build_plan_from_rows(rows)
|
||||||
|
|
||||||
|
def test_shadow_version_attaches_to_primary_model(self):
|
||||||
|
reg = self._registry_with(('a', []))
|
||||||
|
rows = [
|
||||||
|
_version_row(1, 'a', ROLE_PRIMARY, version='1.0.0'),
|
||||||
|
_version_row(2, 'a', ROLE_SHADOW, version='1.1.0-shadow'),
|
||||||
|
_version_row(3, 'a', ROLE_SHADOW, version='1.2.0-shadow'),
|
||||||
|
]
|
||||||
|
plan = reg.build_plan_from_rows(rows)
|
||||||
|
self.assertIn('a', plan.primaries)
|
||||||
|
self.assertEqual(plan.primaries['a'].version_id, 1)
|
||||||
|
self.assertEqual(len(plan.shadows['a']), 2)
|
||||||
|
|
||||||
|
def test_unknown_model_id_skipped(self):
|
||||||
|
reg = ModelRegistry() # 클래스 없음
|
||||||
|
rows = [_version_row(1, 'ghost', ROLE_PRIMARY)]
|
||||||
|
plan = reg.build_plan_from_rows(rows)
|
||||||
|
self.assertNotIn('ghost', plan.primaries)
|
||||||
|
|
||||||
|
def test_class_depends_on_added_to_edges(self):
|
||||||
|
reg = self._registry_with(
|
||||||
|
('base', []),
|
||||||
|
('child', ['base']),
|
||||||
|
)
|
||||||
|
rows = [
|
||||||
|
_version_row(1, 'base', ROLE_PRIMARY),
|
||||||
|
_version_row(2, 'child', ROLE_PRIMARY),
|
||||||
|
]
|
||||||
|
plan = reg.build_plan_from_rows(rows)
|
||||||
|
self.assertIn('base', plan.edges['child'])
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# DAGExecutor 불변식
|
||||||
|
# ======================================================================
|
||||||
|
class DAGExecutorTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def _collect_persisted(self):
|
||||||
|
"""persist 훅 2개를 만들어 호출을 가로채는 pair 반환."""
|
||||||
|
persisted_rows: list[ModelResult] = []
|
||||||
|
persisted_metrics: list[ModelResult] = []
|
||||||
|
|
||||||
|
def p_rows(result: ModelResult, cycle_started_at, *, conn=None):
|
||||||
|
persisted_rows.append(result)
|
||||||
|
|
||||||
|
def p_metrics(result: ModelResult, cycle_started_at, *, conn=None):
|
||||||
|
persisted_metrics.append(result)
|
||||||
|
|
||||||
|
return persisted_rows, persisted_metrics, p_rows, p_metrics
|
||||||
|
|
||||||
|
def _ctx(self):
|
||||||
|
return ModelContext(cycle_started_at=datetime(2026, 4, 20, 0, 0, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
def test_primary_result_injected_into_shared(self):
|
||||||
|
reg = ModelRegistry()
|
||||||
|
reg.register_class(_make_model_class('a'))
|
||||||
|
reg.register_class(_make_model_class('b', ['a']))
|
||||||
|
rows = [
|
||||||
|
_version_row(1, 'a', ROLE_PRIMARY),
|
||||||
|
_version_row(2, 'b', ROLE_PRIMARY),
|
||||||
|
]
|
||||||
|
plan = reg.build_plan_from_rows(rows)
|
||||||
|
|
||||||
|
pr, pm, p1, p2 = self._collect_persisted()
|
||||||
|
ctx = self._ctx()
|
||||||
|
DAGExecutor(plan, persist_fn=p1, persist_metrics_fn=p2).run(ctx)
|
||||||
|
|
||||||
|
self.assertIn('a', ctx.shared)
|
||||||
|
self.assertIn('b', ctx.shared)
|
||||||
|
self.assertEqual(ctx.shared['a'].role, ROLE_PRIMARY)
|
||||||
|
|
||||||
|
def test_shadow_result_not_injected_into_shared(self):
|
||||||
|
"""가장 중요한 불변식 — SHADOW 결과가 ctx.shared 에 들어가면 오염."""
|
||||||
|
reg = ModelRegistry()
|
||||||
|
reg.register_class(_make_model_class('m'))
|
||||||
|
rows = [
|
||||||
|
_version_row(1, 'm', ROLE_PRIMARY),
|
||||||
|
_version_row(2, 'm', ROLE_SHADOW),
|
||||||
|
]
|
||||||
|
plan = reg.build_plan_from_rows(rows)
|
||||||
|
|
||||||
|
pr, pm, p1, p2 = self._collect_persisted()
|
||||||
|
ctx = self._ctx()
|
||||||
|
DAGExecutor(plan, persist_fn=p1, persist_metrics_fn=p2).run(ctx)
|
||||||
|
|
||||||
|
# shared 는 PRIMARY 만
|
||||||
|
self.assertEqual(ctx.shared['m'].role, ROLE_PRIMARY)
|
||||||
|
self.assertEqual(ctx.shared['m'].version_id, 1)
|
||||||
|
|
||||||
|
# 저장은 둘 다 된다
|
||||||
|
persisted_roles = {r.role for r in pr}
|
||||||
|
self.assertIn(ROLE_PRIMARY, persisted_roles)
|
||||||
|
self.assertIn(ROLE_SHADOW, persisted_roles)
|
||||||
|
|
||||||
|
def test_downstream_sees_primary_only_even_when_shadow_differs(self):
|
||||||
|
"""SHADOW 가 다른 값을 리턴해도 후행 PRIMARY 는 선행 PRIMARY 결과만 소비."""
|
||||||
|
|
||||||
|
class M_A(BaseDetectionModel):
|
||||||
|
model_id = 'a'
|
||||||
|
depends_on = []
|
||||||
|
|
||||||
|
def run(self, ctx):
|
||||||
|
val = 100 if self.role == ROLE_PRIMARY else 999
|
||||||
|
return ModelResult(
|
||||||
|
model_id='a', version_id=self.version_id,
|
||||||
|
version_str=self.version_str, role=self.role,
|
||||||
|
outputs_per_input=[(make_input_ref('x'), {'v': val})],
|
||||||
|
metrics={},
|
||||||
|
)
|
||||||
|
|
||||||
|
observed = {'downstream_seen_value': None}
|
||||||
|
|
||||||
|
class M_B(BaseDetectionModel):
|
||||||
|
model_id = 'b'
|
||||||
|
depends_on = ['a']
|
||||||
|
|
||||||
|
def run(self, ctx):
|
||||||
|
upstream = ctx.shared.get('a')
|
||||||
|
observed['downstream_seen_value'] = (
|
||||||
|
upstream.outputs_per_input[0][1]['v'] if upstream else None
|
||||||
|
)
|
||||||
|
return ModelResult(
|
||||||
|
model_id='b', version_id=self.version_id,
|
||||||
|
version_str=self.version_str, role=self.role,
|
||||||
|
outputs_per_input=[(make_input_ref('x'), {'echo': observed['downstream_seen_value']})],
|
||||||
|
)
|
||||||
|
|
||||||
|
reg = ModelRegistry()
|
||||||
|
reg.register_class(M_A)
|
||||||
|
reg.register_class(M_B)
|
||||||
|
rows = [
|
||||||
|
_version_row(1, 'a', ROLE_PRIMARY),
|
||||||
|
_version_row(2, 'a', ROLE_SHADOW),
|
||||||
|
_version_row(3, 'b', ROLE_PRIMARY),
|
||||||
|
]
|
||||||
|
plan = reg.build_plan_from_rows(rows)
|
||||||
|
|
||||||
|
pr, pm, p1, p2 = self._collect_persisted()
|
||||||
|
ctx = self._ctx()
|
||||||
|
DAGExecutor(plan, persist_fn=p1, persist_metrics_fn=p2).run(ctx)
|
||||||
|
|
||||||
|
# downstream 이 본 값은 PRIMARY(100), SHADOW(999) 가 아님
|
||||||
|
self.assertEqual(observed['downstream_seen_value'], 100)
|
||||||
|
|
||||||
|
def test_primary_failure_skips_downstream(self):
|
||||||
|
reg = ModelRegistry()
|
||||||
|
reg.register_class(_make_model_class('a', raise_for_role=ROLE_PRIMARY))
|
||||||
|
reg.register_class(_make_model_class('b', ['a']))
|
||||||
|
rows = [
|
||||||
|
_version_row(1, 'a', ROLE_PRIMARY),
|
||||||
|
_version_row(2, 'b', ROLE_PRIMARY),
|
||||||
|
]
|
||||||
|
plan = reg.build_plan_from_rows(rows)
|
||||||
|
|
||||||
|
pr, pm, p1, p2 = self._collect_persisted()
|
||||||
|
ctx = self._ctx()
|
||||||
|
summary = DAGExecutor(plan, persist_fn=p1, persist_metrics_fn=p2).run(ctx)
|
||||||
|
|
||||||
|
self.assertNotIn('a', ctx.shared)
|
||||||
|
self.assertNotIn('b', ctx.shared)
|
||||||
|
self.assertGreaterEqual(summary['failed'], 1)
|
||||||
|
self.assertGreaterEqual(summary['skipped_missing_deps'], 1)
|
||||||
|
|
||||||
|
def test_shadow_failure_does_not_affect_primary_or_persist(self):
|
||||||
|
cls_ok_primary = _make_model_class('m')
|
||||||
|
cls_bad_shadow = _make_model_class('m', raise_for_role=ROLE_SHADOW)
|
||||||
|
# 같은 model_id 를 다른 클래스로 덮으면 Registry 가 ValueError — 대신 같은 클래스 재사용
|
||||||
|
reg = ModelRegistry()
|
||||||
|
reg.register_class(_make_model_class('m', raise_for_role=ROLE_SHADOW))
|
||||||
|
rows = [
|
||||||
|
_version_row(1, 'm', ROLE_PRIMARY),
|
||||||
|
_version_row(2, 'm', ROLE_SHADOW),
|
||||||
|
]
|
||||||
|
plan = reg.build_plan_from_rows(rows)
|
||||||
|
|
||||||
|
pr, pm, p1, p2 = self._collect_persisted()
|
||||||
|
ctx = self._ctx()
|
||||||
|
summary = DAGExecutor(plan, persist_fn=p1, persist_metrics_fn=p2).run(ctx)
|
||||||
|
|
||||||
|
self.assertEqual(summary['executed'], 1) # PRIMARY 성공
|
||||||
|
self.assertEqual(summary['shadow_failed'], 1)
|
||||||
|
self.assertEqual(summary['shadow_ran'], 0)
|
||||||
|
# PRIMARY 는 persist 된다
|
||||||
|
self.assertEqual([r.role for r in pr], [ROLE_PRIMARY])
|
||||||
|
|
||||||
|
def test_shadow_only_without_primary_is_skipped(self):
|
||||||
|
reg = ModelRegistry()
|
||||||
|
reg.register_class(_make_model_class('orphan'))
|
||||||
|
rows = [_version_row(1, 'orphan', ROLE_SHADOW)]
|
||||||
|
plan = reg.build_plan_from_rows(rows)
|
||||||
|
|
||||||
|
pr, pm, p1, p2 = self._collect_persisted()
|
||||||
|
ctx = self._ctx()
|
||||||
|
summary = DAGExecutor(plan, persist_fn=p1, persist_metrics_fn=p2).run(ctx)
|
||||||
|
self.assertEqual(summary['executed'], 0)
|
||||||
|
self.assertNotIn('orphan', ctx.shared)
|
||||||
|
|
||||||
|
|
||||||
|
class SilentErrorGuardTest(unittest.TestCase):
|
||||||
|
"""V034 스키마 컬럼 사이즈 초과 silent 실패 방지."""
|
||||||
|
|
||||||
|
def test_model_id_too_long_rejected_at_instantiation(self):
|
||||||
|
class _TooLong(BaseDetectionModel):
|
||||||
|
model_id = 'x' * 65 # VARCHAR(64) 초과
|
||||||
|
|
||||||
|
def run(self, ctx): # pragma: no cover
|
||||||
|
return ModelResult(
|
||||||
|
model_id=self.model_id, version_id=self.version_id,
|
||||||
|
version_str=self.version_str, role=self.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
_TooLong(version_id=1, version_str='1', role=ROLE_PRIMARY, params={})
|
||||||
|
|
||||||
|
def test_long_metric_key_dropped_with_warning(self):
|
||||||
|
"""_persist_metrics 가 64자 초과 metric_key 를 dropna silent 로 저장하지 않는다."""
|
||||||
|
from models_core import executor as ex
|
||||||
|
|
||||||
|
# fake conn (cursor context manager 불필요 — _execute_insert 가 단순 호출)
|
||||||
|
captured_rows: list = []
|
||||||
|
|
||||||
|
def fake_exec(sql, rows, *, conn=None):
|
||||||
|
captured_rows.extend(rows)
|
||||||
|
|
||||||
|
orig = ex._execute_insert
|
||||||
|
ex._execute_insert = fake_exec
|
||||||
|
try:
|
||||||
|
r = ModelResult(
|
||||||
|
model_id='m', version_id=1, version_str='1', role=ROLE_PRIMARY,
|
||||||
|
outputs_per_input=[],
|
||||||
|
metrics={
|
||||||
|
'ok_key': 1.0,
|
||||||
|
'x' * 65: 2.0, # 초과
|
||||||
|
},
|
||||||
|
duration_ms=10,
|
||||||
|
)
|
||||||
|
ex._persist_metrics(r, cycle_started_at=datetime(2026, 4, 20))
|
||||||
|
keys = [row[3] for row in captured_rows] # 4번째 컬럼이 metric_key
|
||||||
|
self.assertIn('ok_key', keys)
|
||||||
|
self.assertNotIn('x' * 65, keys)
|
||||||
|
# cycle_duration_ms / output_count 기본값은 포함
|
||||||
|
self.assertIn('cycle_duration_ms', keys)
|
||||||
|
self.assertIn('output_count', keys)
|
||||||
|
finally:
|
||||||
|
ex._execute_insert = orig
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
66
prediction/tests/test_pair_trawl_params.py
Normal file
66
prediction/tests/test_pair_trawl_params.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"""Phase 2 PoC #5 — pair_trawl_tier DEFAULT_PARAMS ↔ seed SQL 정적 일치."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
if 'pandas' not in sys.modules:
|
||||||
|
pd_stub = types.ModuleType('pandas')
|
||||||
|
pd_stub.DataFrame = type('DataFrame', (), {})
|
||||||
|
pd_stub.Timestamp = type('Timestamp', (), {})
|
||||||
|
sys.modules['pandas'] = pd_stub
|
||||||
|
|
||||||
|
if 'pydantic_settings' not in sys.modules:
|
||||||
|
stub = types.ModuleType('pydantic_settings')
|
||||||
|
|
||||||
|
class _S:
|
||||||
|
def __init__(self, **kw):
|
||||||
|
for name, value in self.__class__.__dict__.items():
|
||||||
|
if name.isupper():
|
||||||
|
setattr(self, name, kw.get(name, value))
|
||||||
|
|
||||||
|
stub.BaseSettings = _S
|
||||||
|
sys.modules['pydantic_settings'] = stub
|
||||||
|
|
||||||
|
if 'algorithms' not in sys.modules:
|
||||||
|
pkg = types.ModuleType('algorithms')
|
||||||
|
pkg.__path__ = [os.path.join(os.path.dirname(__file__), '..', 'algorithms')]
|
||||||
|
sys.modules['algorithms'] = pkg
|
||||||
|
|
||||||
|
|
||||||
|
class PairTrawlParamsTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_seed_matches_default(self):
|
||||||
|
pt = importlib.import_module('algorithms.pair_trawl')
|
||||||
|
seed_path = os.path.join(
|
||||||
|
os.path.dirname(__file__), '..',
|
||||||
|
'models_core', 'seeds', 'v1_pair_trawl.sql',
|
||||||
|
)
|
||||||
|
with open(seed_path, 'r', encoding='utf-8') as f:
|
||||||
|
sql = f.read()
|
||||||
|
start = sql.index('$json$') + len('$json$')
|
||||||
|
end = sql.index('$json$', start)
|
||||||
|
params = json.loads(sql[start:end].strip())
|
||||||
|
self.assertEqual(params, pt.PAIR_TRAWL_DEFAULT_PARAMS)
|
||||||
|
|
||||||
|
def test_default_values_match_module_constants(self):
|
||||||
|
pt = importlib.import_module('algorithms.pair_trawl')
|
||||||
|
d = pt.PAIR_TRAWL_DEFAULT_PARAMS
|
||||||
|
self.assertEqual(d['strong']['proximity_nm'], pt.PROXIMITY_NM)
|
||||||
|
self.assertEqual(d['strong']['sog_delta_max'], pt.SOG_DELTA_MAX)
|
||||||
|
self.assertEqual(d['strong']['cog_delta_max'], pt.COG_DELTA_MAX)
|
||||||
|
self.assertEqual(d['strong']['min_sync_cycles'], pt.MIN_SYNC_CYCLES)
|
||||||
|
self.assertEqual(d['strong']['simultaneous_gap_min'], pt.SIMULTANEOUS_GAP_MIN)
|
||||||
|
self.assertEqual(d['probable']['min_block_cycles'], pt.PROBABLE_MIN_BLOCK_CYCLES)
|
||||||
|
self.assertEqual(d['probable']['min_sync_ratio'], pt.PROBABLE_MIN_SYNC_RATIO)
|
||||||
|
self.assertEqual(d['suspect']['min_block_cycles'], pt.SUSPECT_MIN_BLOCK_CYCLES)
|
||||||
|
self.assertEqual(d['suspect']['min_sync_ratio'], pt.SUSPECT_MIN_SYNC_RATIO)
|
||||||
|
self.assertEqual(d['candidate_scan']['cell_size_deg'], pt.CELL_SIZE)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
75
prediction/tests/test_risk_composite_params.py
Normal file
75
prediction/tests/test_risk_composite_params.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"""Phase 2 PoC #4 — risk_composite DEFAULT_PARAMS ↔ seed SQL 정적 일치 테스트."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
if 'pandas' not in sys.modules:
|
||||||
|
pd_stub = types.ModuleType('pandas')
|
||||||
|
pd_stub.DataFrame = type('DataFrame', (), {})
|
||||||
|
pd_stub.Timestamp = type('Timestamp', (), {})
|
||||||
|
sys.modules['pandas'] = pd_stub
|
||||||
|
|
||||||
|
if 'pydantic_settings' not in sys.modules:
|
||||||
|
stub = types.ModuleType('pydantic_settings')
|
||||||
|
|
||||||
|
class _S:
|
||||||
|
def __init__(self, **kw):
|
||||||
|
for name, value in self.__class__.__dict__.items():
|
||||||
|
if name.isupper():
|
||||||
|
setattr(self, name, kw.get(name, value))
|
||||||
|
|
||||||
|
stub.BaseSettings = _S
|
||||||
|
sys.modules['pydantic_settings'] = stub
|
||||||
|
|
||||||
|
if 'algorithms' not in sys.modules:
|
||||||
|
pkg = types.ModuleType('algorithms')
|
||||||
|
pkg.__path__ = [os.path.join(os.path.dirname(__file__), '..', 'algorithms')]
|
||||||
|
sys.modules['algorithms'] = pkg
|
||||||
|
|
||||||
|
# risk.py 는 algorithms.location/fishing_pattern/dark_vessel/spoofing 을 top-level
|
||||||
|
# import 한다. dark_vessel 만 실제 모듈 그대로 두고 나머지는 필요한 심볼만 stub.
|
||||||
|
if 'algorithms.location' in sys.modules:
|
||||||
|
if not hasattr(sys.modules['algorithms.location'], 'classify_zone'):
|
||||||
|
sys.modules['algorithms.location'].classify_zone = lambda *a, **k: {}
|
||||||
|
else:
|
||||||
|
loc = types.ModuleType('algorithms.location')
|
||||||
|
loc.haversine_nm = lambda a, b, c, d: 0.0
|
||||||
|
loc.classify_zone = lambda *a, **k: {}
|
||||||
|
sys.modules['algorithms.location'] = loc
|
||||||
|
|
||||||
|
for mod_name, attrs in [
|
||||||
|
('algorithms.fishing_pattern', ['detect_fishing_segments', 'detect_trawl_uturn']),
|
||||||
|
('algorithms.spoofing', ['detect_teleportation', 'count_speed_jumps']),
|
||||||
|
]:
|
||||||
|
if mod_name not in sys.modules:
|
||||||
|
m = types.ModuleType(mod_name)
|
||||||
|
sys.modules[mod_name] = m
|
||||||
|
m = sys.modules[mod_name]
|
||||||
|
for a in attrs:
|
||||||
|
if not hasattr(m, a):
|
||||||
|
setattr(m, a, lambda *_a, **_kw: [])
|
||||||
|
|
||||||
|
|
||||||
|
class RiskCompositeParamsTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_seed_matches_default(self):
|
||||||
|
risk = importlib.import_module('algorithms.risk')
|
||||||
|
seed_path = os.path.join(
|
||||||
|
os.path.dirname(__file__), '..',
|
||||||
|
'models_core', 'seeds', 'v1_risk_composite.sql',
|
||||||
|
)
|
||||||
|
with open(seed_path, 'r', encoding='utf-8') as f:
|
||||||
|
sql = f.read()
|
||||||
|
start = sql.index('$json$') + len('$json$')
|
||||||
|
end = sql.index('$json$', start)
|
||||||
|
params = json.loads(sql[start:end].strip())
|
||||||
|
self.assertEqual(params, risk.RISK_COMPOSITE_DEFAULT_PARAMS)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
88
prediction/tests/test_transshipment_params.py
Normal file
88
prediction/tests/test_transshipment_params.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
"""Phase 2 PoC #3 — transshipment_5stage params 카탈로그 동치성 테스트.
|
||||||
|
|
||||||
|
런타임 override 는 후속 PR 에서 활성화되므로, 이 테스트는 **DEFAULT_PARAMS
|
||||||
|
↔ 모듈 상수 ↔ seed SQL JSONB** 3 자 일치만 검증한다.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
# pandas/pydantic_settings stub (다른 phase 2 테스트와 동일 관용)
|
||||||
|
if 'pandas' not in sys.modules:
|
||||||
|
pd_stub = types.ModuleType('pandas')
|
||||||
|
pd_stub.DataFrame = type('DataFrame', (), {})
|
||||||
|
pd_stub.Timestamp = type('Timestamp', (), {})
|
||||||
|
sys.modules['pandas'] = pd_stub
|
||||||
|
|
||||||
|
if 'pydantic_settings' not in sys.modules:
|
||||||
|
stub = types.ModuleType('pydantic_settings')
|
||||||
|
|
||||||
|
class _S:
|
||||||
|
def __init__(self, **kw):
|
||||||
|
for name, value in self.__class__.__dict__.items():
|
||||||
|
if name.isupper():
|
||||||
|
setattr(self, name, kw.get(name, value))
|
||||||
|
|
||||||
|
stub.BaseSettings = _S
|
||||||
|
sys.modules['pydantic_settings'] = stub
|
||||||
|
|
||||||
|
if 'algorithms' not in sys.modules:
|
||||||
|
pkg = types.ModuleType('algorithms')
|
||||||
|
pkg.__path__ = [os.path.join(os.path.dirname(__file__), '..', 'algorithms')]
|
||||||
|
sys.modules['algorithms'] = pkg
|
||||||
|
|
||||||
|
# fleet_tracker 의 GEAR_PATTERN 을 transshipment.py 상단에서 import 하므로 stub
|
||||||
|
if 'fleet_tracker' not in sys.modules:
|
||||||
|
ft_stub = types.ModuleType('fleet_tracker')
|
||||||
|
import re as _re
|
||||||
|
ft_stub.GEAR_PATTERN = _re.compile(r'^xxx$')
|
||||||
|
sys.modules['fleet_tracker'] = ft_stub
|
||||||
|
|
||||||
|
ts = importlib.import_module('algorithms.transshipment')
|
||||||
|
|
||||||
|
|
||||||
|
class TransshipmentParamsTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_default_values_match_module_constants(self):
|
||||||
|
p = ts.TRANSSHIPMENT_DEFAULT_PARAMS
|
||||||
|
self.assertEqual(p['sog_threshold_kn'], ts.SOG_THRESHOLD_KN)
|
||||||
|
self.assertEqual(p['proximity_deg'], ts.PROXIMITY_DEG)
|
||||||
|
self.assertEqual(p['approach_deg'], ts.APPROACH_DEG)
|
||||||
|
self.assertEqual(p['rendezvous_min'], ts.RENDEZVOUS_MIN)
|
||||||
|
self.assertEqual(p['pair_expiry_min'], ts.PAIR_EXPIRY_MIN)
|
||||||
|
self.assertEqual(p['gap_tolerance_cycles'], ts.GAP_TOLERANCE_CYCLES)
|
||||||
|
self.assertEqual(set(p['fishing_kinds']), set(ts._FISHING_KINDS))
|
||||||
|
self.assertEqual(set(p['carrier_kinds']), set(ts._CARRIER_KINDS))
|
||||||
|
self.assertEqual(set(p['excluded_ship_ty']), set(ts._EXCLUDED_SHIP_TY))
|
||||||
|
self.assertEqual(list(p['carrier_hints']), list(ts._CARRIER_HINTS))
|
||||||
|
|
||||||
|
def test_seed_sql_values_match_python_default(self):
|
||||||
|
seed_path = os.path.join(
|
||||||
|
os.path.dirname(__file__), '..',
|
||||||
|
'models_core', 'seeds', 'v1_transshipment.sql',
|
||||||
|
)
|
||||||
|
with open(seed_path, 'r', encoding='utf-8') as f:
|
||||||
|
sql = f.read()
|
||||||
|
|
||||||
|
start = sql.index('$json$') + len('$json$')
|
||||||
|
end = sql.index('$json$', start)
|
||||||
|
raw = sql[start:end].strip()
|
||||||
|
params = json.loads(raw)
|
||||||
|
|
||||||
|
expected = ts.TRANSSHIPMENT_DEFAULT_PARAMS
|
||||||
|
for scalar_key in ['sog_threshold_kn', 'proximity_deg', 'approach_deg',
|
||||||
|
'rendezvous_min', 'pair_expiry_min', 'gap_tolerance_cycles',
|
||||||
|
'min_score']:
|
||||||
|
self.assertEqual(params[scalar_key], expected[scalar_key], scalar_key)
|
||||||
|
for list_key in ['fishing_kinds', 'carrier_kinds', 'excluded_ship_ty',
|
||||||
|
'carrier_hints']:
|
||||||
|
self.assertEqual(set(params[list_key]), set(expected[list_key]), list_key)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
불러오는 중...
Reference in New Issue
Block a user