From dae7aea8610bc144302529c57569e27bc1fabd28 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 20 Apr 2026 06:22:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(db):=20Detection=20Model=20Registry=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=20(V034,=20Phase=201-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prediction 의 17 탐지 알고리즘을 "명시적 모델 단위" 로 분리하고 프론트엔드에서 파라미터·버전을 관리할 수 있도록 하는 기반 인프라의 DB 계층. 기존 V014 correlation_param_models 패턴을 일반화: - JSONB params + is_active(V014) → model_id × version × role×status (V034) - 한 모델을 여러 파라미터 셋으로 동시 실행 지원 (PRIMARY/SHADOW/CHALLENGER) - Compare API 기반 제공 (PRIMARY vs SHADOW diff 집계) ### 스키마 (테이블 4 + 뷰 1) 1. detection_models — 모델 카탈로그 (model_id PK, tier 1~5, category, entry_module/callable, is_enabled) 2. detection_model_dependencies — 모델 간 DAG 엣지 (self-loop 금지, input_key 포함) 3. detection_model_versions — 파라미터 스냅샷 + 라이프사이클 + role - status: DRAFT / TESTING / ACTIVE / ARCHIVED - role: PRIMARY / SHADOW / CHALLENGER (ACTIVE 일 때만) - UNIQUE partial index: model_id 당 PRIMARY×ACTIVE 최대 1건 - SHADOW/CHALLENGER×ACTIVE 는 N건 허용 (병렬 비교) - parent_version_id 로 fork 계보 추적 4. detection_model_run_outputs — 버전별 실행 결과 원시 snapshot - PARTITION BY RANGE (cycle_started_at) - 초기 2개월(2026-04, 2026-05) 파티션 + 이후 월별 자동생성 TODO (Phase 1-2) - input_ref JSONB GIN index — 같은 입력 기준 PRIMARY×SHADOW JOIN 용 5. detection_model_metrics — 사이클 단위 집계 메트릭 (cycle_duration_ms 등) 6. v_detection_model_comparison — PRIMARY×SHADOW 같은 입력으로 JOIN 한 VIEW ### 권한 - auth_perm_tree: 'ai-operations:detection-models' (parent=admin, nav_sort=250) - ADMIN 5 ops / OPERATOR READ+UPDATE / ANALYST·VIEWER READ ### 후속 (Phase 1-2) - partition_manager 에 detection_model_run_outputs 월별 자동 생성/만료 로직 추가 - 기본 retention 7일 (SHADOW 대량 누적 대비) ### 검증 - Flyway 자동 적용 (백엔드 재빌드+배포) - 이후 Python Model Registry 가 이 스키마 위에서 동작 --- .../db/migration/V034__detection_models.sql | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V034__detection_models.sql 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;