feat(db): Detection Model Registry 스키마 (V034, Phase 1-1) #87

병합
htlee feature/phase1-1-detection-models-schema 에서 develop 로 3 commits 를 머지했습니다 2026-04-20 06:49:02 +09:00
2개의 변경된 파일229개의 추가작업 그리고 0개의 파일을 삭제

파일 보기

@ -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;

파일 보기

@ -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 경유)