diff --git a/backend/src/main/resources/db/migration/V034__detection_models.sql b/backend/src/main/resources/db/migration/V034__detection_models.sql new file mode 100644 index 0000000..ba521b8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V034__detection_models.sql @@ -0,0 +1,226 @@ +-- V034: Detection Model Registry 기반 스키마 (Phase 1-1) +-- +-- prediction 모듈의 17 탐지 알고리즘을 "명시적 모델 단위" 로 분리하고, +-- 운영자가 프론트엔드에서 파라미터·버전을 관리할 수 있도록 하는 기반 인프라. +-- 핵심 개념: +-- * Model — 독립 탐지 단위 (dark_suspicion / gear_violation_g01_g06 등) +-- * Version — 같은 model 의 파라미터 스냅샷. 라이프사이클 DRAFT→ACTIVE→ARCHIVED +-- * Role — PRIMARY(운영 반영, 최대 1개) / SHADOW·CHALLENGER(관측용, N개) +-- * DAG — model_id 간 선행·후행 의존성 (선행 PRIMARY 결과만 후행에 주입) +-- +-- 기존 V014 correlation_param_models 패턴의 일반화 — JSONB params + is_active 를 +-- model_id × version × role 차원으로 확장. V014 는 Phase 2 에서 이 스키마로 +-- 이주 후 2~3 릴리즈 후 deprecate 예정. +-- +-- 참고: docs/prediction-analysis.md §7 P1/P2 + .claude/plans/vast-tinkering-knuth.md + +-- ══════════════════════════════════════════════════════════════════ +-- 1. detection_models — 모델 카탈로그 (고정 메타) +-- ══════════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS kcg.detection_models ( + model_id VARCHAR(64) PRIMARY KEY, + display_name VARCHAR(200) NOT NULL, + tier INT NOT NULL, -- 1(Primitive) ~ 5(Meta) + category VARCHAR(40), -- DARK_VESSEL/GEAR/PATTERN/TRANSSHIP/META + description TEXT, + entry_module VARCHAR(200) NOT NULL, -- 'prediction.algorithms.dark_vessel' + entry_callable VARCHAR(100) NOT NULL, -- 'compute_dark_suspicion' + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, -- 전역 ON/OFF 킬스위치 + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + CHECK (tier BETWEEN 1 AND 5) +); + +CREATE INDEX IF NOT EXISTS idx_detection_models_tier + ON kcg.detection_models(tier, category); +CREATE INDEX IF NOT EXISTS idx_detection_models_enabled + ON kcg.detection_models(is_enabled) WHERE is_enabled = TRUE; + +COMMENT ON TABLE kcg.detection_models IS + '탐지 모델 카탈로그 — prediction Model 인터페이스의 단위 정의'; + +-- ══════════════════════════════════════════════════════════════════ +-- 2. detection_model_dependencies — 모델 간 DAG 엣지 +-- ══════════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS kcg.detection_model_dependencies ( + model_id VARCHAR(64) NOT NULL + REFERENCES kcg.detection_models(model_id) ON DELETE CASCADE, + depends_on VARCHAR(64) NOT NULL + REFERENCES kcg.detection_models(model_id) ON DELETE RESTRICT, + input_key VARCHAR(64) NOT NULL, -- 'gap_info' / 'pair_result' 등 + PRIMARY KEY (model_id, depends_on, input_key), + CHECK (model_id <> depends_on) -- self-loop 금지 +); + +CREATE INDEX IF NOT EXISTS idx_detection_model_deps_reverse + ON kcg.detection_model_dependencies(depends_on); + +COMMENT ON TABLE kcg.detection_model_dependencies IS + '모델 실행 DAG — 선행 model PRIMARY 결과의 어떤 key 를 후행이 입력으로 쓰는지'; + +-- ══════════════════════════════════════════════════════════════════ +-- 3. detection_model_versions — 파라미터 스냅샷 + 라이프사이클 + role +-- ══════════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS kcg.detection_model_versions ( + id BIGSERIAL PRIMARY KEY, + model_id VARCHAR(64) NOT NULL + REFERENCES kcg.detection_models(model_id) ON DELETE CASCADE, + version VARCHAR(32) NOT NULL, -- SemVer 권장 '1.0.0', 자유도 허용 + status VARCHAR(20) NOT NULL, -- DRAFT / TESTING / ACTIVE / ARCHIVED + role VARCHAR(20), -- PRIMARY / SHADOW / CHALLENGER (ACTIVE 일 때만) + params JSONB NOT NULL, -- 임계값·가중치·상수 + notes TEXT, + traffic_weight INT DEFAULT 0, -- CHALLENGER split (0~100, 후속 기능) + parent_version_id BIGINT + REFERENCES kcg.detection_model_versions(id) ON DELETE SET NULL, + created_by UUID, + created_at TIMESTAMPTZ DEFAULT now(), + activated_at TIMESTAMPTZ, + archived_at TIMESTAMPTZ, + UNIQUE (model_id, version), + CHECK (status IN ('DRAFT','TESTING','ACTIVE','ARCHIVED')), + CHECK (role IS NULL OR role IN ('PRIMARY','SHADOW','CHALLENGER')), + CHECK (status <> 'ACTIVE' OR role IS NOT NULL), -- ACTIVE → role 필수 + CHECK (traffic_weight BETWEEN 0 AND 100) +); + +-- 한 model_id 에 PRIMARY×ACTIVE 는 최대 1건 (운영 반영 보호) +-- SHADOW/CHALLENGER×ACTIVE 는 N 건 허용 +CREATE UNIQUE INDEX IF NOT EXISTS uk_detection_model_primary + ON kcg.detection_model_versions(model_id) + WHERE status = 'ACTIVE' AND role = 'PRIMARY'; + +CREATE INDEX IF NOT EXISTS idx_detection_model_versions_active + ON kcg.detection_model_versions(model_id, status) + WHERE status = 'ACTIVE'; +CREATE INDEX IF NOT EXISTS idx_detection_model_versions_status + ON kcg.detection_model_versions(status, model_id); + +COMMENT ON TABLE kcg.detection_model_versions IS + '모델 버전 + 파라미터 + 라이프사이클. ACTIVE 는 role=PRIMARY 1개 + SHADOW/CHALLENGER N개'; + +-- ══════════════════════════════════════════════════════════════════ +-- 4. detection_model_run_outputs — 버전별 입력·출력 비교 (파티션) +-- 같은 (cycle, model_id, input_ref) 로 PRIMARY vs SHADOW JOIN 비교 +-- ══════════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS kcg.detection_model_run_outputs ( + id BIGSERIAL, + cycle_started_at TIMESTAMPTZ NOT NULL, -- 같은 사이클 식별 (모든 버전 공유) + model_id VARCHAR(64) NOT NULL, + version_id BIGINT NOT NULL, + role VARCHAR(20) NOT NULL, -- PRIMARY / SHADOW / CHALLENGER + input_ref JSONB NOT NULL, -- {mmsi, analyzed_at} 등 + outputs JSONB NOT NULL, -- 모델 반환값 snapshot + cycle_duration_ms INT, + recorded_at TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (id, cycle_started_at), + CHECK (role IN ('PRIMARY','SHADOW','CHALLENGER')) +) PARTITION BY RANGE (cycle_started_at); + +-- 2026-04 파티션 (이후는 partition_manager 가 월별 자동 생성) +CREATE TABLE IF NOT EXISTS kcg.detection_model_run_outputs_2026_04 + PARTITION OF kcg.detection_model_run_outputs + FOR VALUES FROM ('2026-04-01') TO ('2026-05-01'); +CREATE TABLE IF NOT EXISTS kcg.detection_model_run_outputs_2026_05 + PARTITION OF kcg.detection_model_run_outputs + FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); + +CREATE INDEX IF NOT EXISTS idx_run_outputs_compare + ON kcg.detection_model_run_outputs(model_id, cycle_started_at DESC, role); +CREATE INDEX IF NOT EXISTS idx_run_outputs_version + ON kcg.detection_model_run_outputs(version_id, cycle_started_at DESC); +CREATE INDEX IF NOT EXISTS idx_run_outputs_input + ON kcg.detection_model_run_outputs USING GIN (input_ref jsonb_path_ops); + +COMMENT ON TABLE kcg.detection_model_run_outputs IS + '버전별 실행 결과 원시 snapshot — PRIMARY vs SHADOW diff 분석용. 월별 파티션, 기본 7일 retention'; + +-- ══════════════════════════════════════════════════════════════════ +-- 5. detection_model_metrics — 사이클 단위 집계 메트릭 +-- ══════════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS kcg.detection_model_metrics ( + id BIGSERIAL PRIMARY KEY, + model_id VARCHAR(64) NOT NULL, + version_id BIGINT NOT NULL + REFERENCES kcg.detection_model_versions(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL, + metric_key VARCHAR(64) NOT NULL, -- cycle_duration_ms/detected_count/tier_critical_count + metric_value NUMERIC, + cycle_started_at TIMESTAMPTZ NOT NULL, + recorded_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_detection_model_metrics_lookup + ON kcg.detection_model_metrics(model_id, version_id, cycle_started_at DESC); +CREATE INDEX IF NOT EXISTS idx_detection_model_metrics_cycle + ON kcg.detection_model_metrics(cycle_started_at DESC, model_id); + +COMMENT ON TABLE kcg.detection_model_metrics IS + '모델 실행 집계 메트릭 — dashboard/compare API 의 관측 소스'; + +-- ══════════════════════════════════════════════════════════════════ +-- 6. Compare VIEW — PRIMARY vs SHADOW 결과를 같은 입력 단위로 JOIN +-- ══════════════════════════════════════════════════════════════════ +CREATE OR REPLACE VIEW kcg.v_detection_model_comparison AS +SELECT + p.cycle_started_at, + p.model_id, + p.input_ref, + p.outputs AS primary_outputs, + s.outputs AS shadow_outputs, + p.version_id AS primary_version_id, + s.version_id AS shadow_version_id, + s.role AS shadow_role +FROM kcg.detection_model_run_outputs p +JOIN kcg.detection_model_run_outputs s + ON p.cycle_started_at = s.cycle_started_at + AND p.model_id = s.model_id + AND p.input_ref = s.input_ref +WHERE p.role = 'PRIMARY' + AND s.role IN ('SHADOW', 'CHALLENGER'); + +COMMENT ON VIEW kcg.v_detection_model_comparison IS + 'PRIMARY × SHADOW/CHALLENGER 동일 입력 결과 비교 — backend Compare API 의 집계 소스'; + +-- ══════════════════════════════════════════════════════════════════ +-- 7. 권한 트리 / 메뉴 슬롯 +-- ai-operations:detection-models — AI 모델관리 하위 혹은 별도 노드. +-- 기존 ai-operations:ai-model(nav=200) 과 구분하기 위해 별도 nav_sort=250. +-- parent_cd='admin' (AI 운영 그룹 평탄화 패턴 따름) +-- ══════════════════════════════════════════════════════════════════ +INSERT INTO kcg.auth_perm_tree + (rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord, + url_path, label_key, component_key, nav_sort, labels) +VALUES + ('ai-operations:detection-models', 'admin', '탐지 모델 관리', 1, 25, + '/detection-models', 'nav.detectionModels', + 'features/ai-operations/DetectionModelManagement', 250, + '{"ko":"탐지 모델 관리","en":"Detection Models"}'::jsonb) +ON CONFLICT (rsrc_cd) DO NOTHING; + +-- ══════════════════════════════════════════════════════════════════ +-- 8. 권한 부여 +-- ADMIN : 5 ops 전부 (모델 카탈로그 관리) +-- OPERATOR : READ + UPDATE (SHADOW activate 정도. promote-primary 는 ADMIN 만) +-- ANALYST/VIEWER: READ (파라미터 조회 + Compare 분석) +-- FIELD : (생략 — 현장 단속 담당, 모델 관리 불필요) +-- ══════════════════════════════════════════════════════════════════ +INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn) +SELECT r.role_sn, 'ai-operations:detection-models', op.oper_cd, 'Y' +FROM kcg.auth_role r +CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd) +WHERE r.role_cd = 'ADMIN' +ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING; + +INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn) +SELECT r.role_sn, 'ai-operations:detection-models', op.oper_cd, 'Y' +FROM kcg.auth_role r +CROSS JOIN (VALUES ('READ'), ('UPDATE')) AS op(oper_cd) +WHERE r.role_cd = 'OPERATOR' +ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING; + +INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn) +SELECT r.role_sn, 'ai-operations:detection-models', 'READ', 'Y' +FROM kcg.auth_role r +WHERE r.role_cd IN ('ANALYST', 'VIEWER') +ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING; diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 7577d03..2675c9e 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -7,6 +7,9 @@ ### 수정 - **모니터링/디자인시스템 런타임 에러 해소** — `/monitoring` 의 `SystemStatusPanel` 에서 `stats.total.toLocaleString()` 호출이 백엔드 응답 shape 이슈로 `stats.total` 이 undefined 일 때 Uncaught TypeError 로 크래시하던 문제 null-safe 로 해소(`stats?.total != null`). `/design-system.html` 의 `CatalogBadges` 가 `PERFORMANCE_STATUS_META` 의 `label: {ko, en}` 객체를 그대로 Badge children 으로 주입해 "Objects are not valid as a React child" 를 던지고 `code` 필드 부재로 key 중복 경고가 함께 뜨던 문제 해소 — `Object.entries` 순회 + `AnyMeta.label` 을 `string | {ko,en}` 로 확장 + getKoLabel/getEnLabel 에 label 객체 케이스 추가 +### 추가 +- **Detection Model Registry DB 스키마 (V034, Phase 1-1)** — prediction 17 탐지 알고리즘을 "명시적 모델 단위" 로 분리하고 프론트엔드에서 파라미터·버전·가중치를 관리할 수 있는 기반 인프라. 테이블 4종(`detection_models` 카탈로그 / `detection_model_dependencies` DAG / `detection_model_versions` 파라미터 스냅샷·라이프사이클·role / `detection_model_run_outputs` 월별 파티션) + 뷰 1개(`v_detection_model_comparison` PRIMARY×SHADOW JOIN). 한 모델을 서로 다른 파라미터로 **동시 실행**(PRIMARY 1 + SHADOW/CHALLENGER N) 지원, ACTIVE×PRIMARY 는 UNIQUE partial index 로 1건 보호. 권한 트리 `ai-operations:detection-models`(nav_sort=250) + ADMIN 5 ops / OPERATOR READ+UPDATE / ANALYST·VIEWER READ. (후속: Phase 1-2 Model Registry + DAG Executor, Phase 2 PoC 5 모델 마이그레이션) + ### 추가 - **환적 의심 전용 탐지 페이지 신설 (Phase 0-3)** — `/transshipment` 경로에 READ 전용 대시보드 추가. prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과(is_transship_suspect=true)를 전체 목록·집계·상세 수준으로 조회. KPI 5장(Total + Transship tier CRITICAL/HIGH/MEDIUM + 종합 위험 CRITICAL) + DataTable 8컬럼 + 필터(hours/level/mmsi) + features JSON 상세. 기존 `/api/analysis/transship` + `getTransshipSuspects` 재사용해 backend 변경 없음. V033 마이그레이션으로 `detection:transshipment` 권한 트리 + 전 역할 READ 부여. (docs/prediction-analysis.md P1 UI 미노출 탐지 해소 — 2/2) - **불법 조업 이벤트 전용 페이지 신설 (Phase 0-2)** — `/illegal-fishing` 경로에 READ 전용 대시보드 추가. event_generator 가 생산하는 `GEAR_ILLEGAL`(G-01/G-05/G-06) + `EEZ_INTRUSION`(영해·접속수역) + `ZONE_DEPARTURE`(특정수역 진입) 3 카테고리를 한 화면에서 통합 조회. 심각도 KPI 5장 + 카테고리별 3장 + DataTable(7컬럼) + 필터(category/level/mmsi) + JSON features 상세 패널 + EventList 네비게이션. 기존 `/api/events` 를 category 다중 병렬 조회로 래핑하여 backend 변경 없이 구현. V032 마이그레이션으로 `detection:illegal-fishing` 권한 트리 + 전 역할 READ 부여 (운영자 처리 액션은 EventList 경유)