From 1244f07de67f708d14f281f348d8182d2849747e Mon Sep 17 00:00:00 2001 From: Nan Kyung Lee Date: Mon, 13 Apr 2026 10:51:05 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20LGCNS=20MLOps=20+=20AI=20=EB=B3=B4?= =?UTF-8?q?=EC=95=88(SER-10)=20+=20AI=20Agent=20=EB=B3=B4=EC=95=88(SER-11)?= =?UTF-8?q?=20=EB=A9=94=EB=89=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V025 마이그레이션: admin 그룹 하위 3개 메뉴 등록 - LGCNS MLOps (AI 플랫폼, nav_sort=350) - AI 보안 (감사·보안, nav_sort=1800) - AI Agent 보안 (감사·보안, nav_sort=1900) - 페이지 컴포넌트 3개 신규 생성 - componentRegistry, i18n(ko/en) 반영 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../db/migration/V025__lgcns_mlops_menu.sql | 76 +++ frontend/src/app/auth/AuthContext.tsx | 2 +- frontend/src/app/componentRegistry.ts | 9 + .../features/admin/AIAgentSecurityPage.tsx | 295 ++++++++++++ .../src/features/admin/AISecurityPage.tsx | 351 ++++++++++++++ frontend/src/features/admin/index.ts | 2 + .../features/ai-operations/LGCNSMLOpsPage.tsx | 431 ++++++++++++++++++ frontend/src/features/ai-operations/index.ts | 1 + frontend/src/lib/i18n/locales/en/ai.json | 4 + frontend/src/lib/i18n/locales/en/common.json | 5 +- frontend/src/lib/i18n/locales/ko/ai.json | 4 + frontend/src/lib/i18n/locales/ko/common.json | 5 +- 12 files changed, 1182 insertions(+), 3 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V025__lgcns_mlops_menu.sql create mode 100644 frontend/src/features/admin/AIAgentSecurityPage.tsx create mode 100644 frontend/src/features/admin/AISecurityPage.tsx create mode 100644 frontend/src/features/ai-operations/LGCNSMLOpsPage.tsx diff --git a/backend/src/main/resources/db/migration/V025__lgcns_mlops_menu.sql b/backend/src/main/resources/db/migration/V025__lgcns_mlops_menu.sql new file mode 100644 index 0000000..de0689a --- /dev/null +++ b/backend/src/main/resources/db/migration/V025__lgcns_mlops_menu.sql @@ -0,0 +1,76 @@ +-- ============================================================ +-- V025: LGCNS MLOps + AI 보안(SER-10) + AI Agent 보안(SER-11) 메뉴 추가 +-- 시스템관리 > AI 플랫폼 / 감사·보안 서브그룹 +-- ============================================================ + +-- ────────────────────────────────────────────────────────────── +-- 1. LGCNS MLOps (시스템관리 > AI 플랫폼, MLOps와 LLM 사이) +-- ────────────────────────────────────────────────────────────── +INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord) +VALUES ('admin:lgcns-mlops', 'admin', 'LGCNS MLOps', 1, 36) +ON CONFLICT (rsrc_cd) DO NOTHING; + +UPDATE kcg.auth_perm_tree +SET url_path = '/lgcns-mlops', + label_key = 'nav.lgcnsMlops', + component_key = 'features/ai-operations/LGCNSMLOpsPage', + nav_group = 'admin', + nav_sub_group = 'AI 플랫폼', + nav_sort = 350, + labels = '{"ko":"LGCNS MLOps","en":"LGCNS MLOps"}' +WHERE rsrc_cd = 'admin:lgcns-mlops'; + +INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn) +SELECT r.role_sn, 'admin:lgcns-mlops', 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; + +-- ────────────────────────────────────────────────────────────── +-- 2. AI 보안 (SER-10) (시스템관리 > 감사·보안, 로그인 이력 뒤) +-- ────────────────────────────────────────────────────────────── +INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord) +VALUES ('admin:ai-security', 'admin', 'AI 보안', 1, 55) +ON CONFLICT (rsrc_cd) DO NOTHING; + +UPDATE kcg.auth_perm_tree +SET url_path = '/admin/ai-security', + label_key = 'nav.aiSecurity', + component_key = 'features/admin/AISecurityPage', + nav_group = 'admin', + nav_sub_group = '감사·보안', + nav_sort = 1800, + labels = '{"ko":"AI 보안","en":"AI Security"}' +WHERE rsrc_cd = 'admin:ai-security'; + +INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn) +SELECT r.role_sn, 'admin:ai-security', 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; + +-- ────────────────────────────────────────────────────────────── +-- 3. AI Agent 보안 (SER-11) (시스템관리 > 감사·보안, AI 보안 뒤) +-- ────────────────────────────────────────────────────────────── +INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord) +VALUES ('admin:ai-agent-security', 'admin', 'AI Agent 보안', 1, 56) +ON CONFLICT (rsrc_cd) DO NOTHING; + +UPDATE kcg.auth_perm_tree +SET url_path = '/admin/ai-agent-security', + label_key = 'nav.aiAgentSecurity', + component_key = 'features/admin/AIAgentSecurityPage', + nav_group = 'admin', + nav_sub_group = '감사·보안', + nav_sort = 1900, + labels = '{"ko":"AI Agent 보안","en":"AI Agent Security"}' +WHERE rsrc_cd = 'admin:ai-agent-security'; + +INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn) +SELECT r.role_sn, 'admin:ai-agent-security', 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; diff --git a/frontend/src/app/auth/AuthContext.tsx b/frontend/src/app/auth/AuthContext.tsx index a11a267..7f0f433 100644 --- a/frontend/src/app/auth/AuthContext.tsx +++ b/frontend/src/app/auth/AuthContext.tsx @@ -1,5 +1,5 @@ import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'; -import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser } from '@/services/authApi'; +import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser, type MenuConfigItem } from '@/services/authApi'; import { useMenuStore } from '@stores/menuStore'; /* diff --git a/frontend/src/app/componentRegistry.ts b/frontend/src/app/componentRegistry.ts index 7f9bd31..caed5d4 100644 --- a/frontend/src/app/componentRegistry.ts +++ b/frontend/src/app/componentRegistry.ts @@ -80,6 +80,9 @@ export const COMPONENT_REGISTRY: Record = { 'features/ai-operations/MLOpsPage': lazy(() => import('@features/ai-operations').then((m) => ({ default: m.MLOpsPage })), ), + 'features/ai-operations/LGCNSMLOpsPage': lazy(() => + import('@features/ai-operations').then((m) => ({ default: m.LGCNSMLOpsPage })), + ), 'features/ai-operations/LLMOpsPage': lazy(() => import('@features/ai-operations').then((m) => ({ default: m.LLMOpsPage })), ), @@ -113,6 +116,12 @@ export const COMPONENT_REGISTRY: Record = { default: m.LoginHistoryView, })), ), + 'features/admin/AISecurityPage': lazy(() => + import('@features/admin').then((m) => ({ default: m.AISecurityPage })), + ), + 'features/admin/AIAgentSecurityPage': lazy(() => + import('@features/admin').then((m) => ({ default: m.AIAgentSecurityPage })), + ), // ── 모선 워크플로우 ── 'features/parent-inference/ParentReview': lazy(() => import('@features/parent-inference/ParentReview').then((m) => ({ diff --git a/frontend/src/features/admin/AIAgentSecurityPage.tsx b/frontend/src/features/admin/AIAgentSecurityPage.tsx new file mode 100644 index 0000000..563eea1 --- /dev/null +++ b/frontend/src/features/admin/AIAgentSecurityPage.tsx @@ -0,0 +1,295 @@ +import { useState } from 'react'; +import { Card, CardContent } from '@shared/components/ui/card'; +import { Badge } from '@shared/components/ui/badge'; +import { PageContainer, PageHeader } from '@shared/components/layout'; +import { getStatusIntent } from '@shared/constants/statusIntent'; +import { + Bot, Shield, Lock, Eye, Activity, AlertTriangle, + CheckCircle, FileText, Settings, Terminal, Users, + Key, Layers, Workflow, Hand, +} from 'lucide-react'; + +/* + * SER-11: AI Agent 구축 운영 + * + * AI Agent 보안 대책 관리 페이지: + * ① Agent 현황 ② 화이트리스트 도구 관리 ③ 자동 중단·민감명령 승인 + * ④ 에이전트 권한·신원 ⑤ MCP Tool 최소권한 ⑥ 감사 로그 + */ + +type Tab = 'overview' | 'whitelist' | 'killswitch' | 'identity' | 'mcp' | 'audit'; + +// ─── Agent 현황 ────────────────── +const AGENTS = [ + { name: '위험도 분석 Agent', type: '조회 전용', tools: 4, status: '활성', calls24h: 1240, lastCall: '04-10 09:28' }, + { name: '법령 Q&A Agent', type: '조회 전용', tools: 3, status: '활성', calls24h: 856, lastCall: '04-10 09:25' }, + { name: '단속 이력 Agent', type: '조회 전용', tools: 5, status: '활성', calls24h: 432, lastCall: '04-10 09:20' }, + { name: '모선 추론 Agent', type: '조회+쓰기', tools: 6, status: '활성', calls24h: 128, lastCall: '04-10 09:15' }, + { name: '데이터 관리 Agent', type: '관리자', tools: 8, status: '대기', calls24h: 0, lastCall: '04-09 16:00' }, +]; + +const AGENT_KPI = [ + { label: '활성 Agent', value: '4', color: '#10b981' }, + { label: '등록 Tool', value: '26', color: '#3b82f6' }, + { label: '24h 호출', value: '2,656', color: '#8b5cf6' }, + { label: '차단 건수', value: '3', color: '#ef4444' }, + { label: '승인 대기', value: '0', color: '#f59e0b' }, +]; + +// ─── 화이트리스트 도구 ────────────────── +const WHITELIST_TOOLS = [ + { tool: 'db_read_vessel', agent: '위험도 분석', permission: 'READ', mcp: 'kcg-db-server', status: '허용', desc: '선박 정보 조회' }, + { tool: 'db_read_analysis', agent: '위험도 분석', permission: 'READ', mcp: 'kcg-db-server', status: '허용', desc: '분석 결과 조회' }, + { tool: 'search_law', agent: '법령 Q&A', permission: 'READ', mcp: 'kcg-rag-server', status: '허용', desc: '법령·판례 검색' }, + { tool: 'search_cases', agent: '법령 Q&A', permission: 'READ', mcp: 'kcg-rag-server', status: '허용', desc: '유사 사례 검색' }, + { tool: 'read_enforcement', agent: '단속 이력', permission: 'READ', mcp: 'kcg-db-server', status: '허용', desc: '단속 이력 조회' }, + { tool: 'write_parent_result', agent: '모선 추론', permission: 'CREATE', mcp: 'kcg-db-server', status: '허용', desc: '모선 추론 결과 저장' }, + { tool: 'update_parent_status', agent: '모선 추론', permission: 'UPDATE', mcp: 'kcg-db-server', status: '허용', desc: '모선 상태 갱신' }, + { tool: 'db_delete_any', agent: '-', permission: 'DELETE', mcp: '-', status: '차단', desc: 'DB 삭제 (금지 도구)' }, + { tool: 'system_exec', agent: '-', permission: 'ADMIN', mcp: '-', status: '차단', desc: '시스템 명령 실행 (금지)' }, +]; + +// ─── 자동 중단 설정 ────────────────── +const KILL_SWITCH_RULES = [ + { rule: '유해·금지행위 탐지', desc: '유해·금지행위, 잘못된 목표 설정 시 Agent 자동 중단', threshold: '즉시', status: '활성' }, + { rule: '자원 소비 임계값 초과', desc: 'GPU/메모리/API 호출 임계값 초과 시 자동 중단', threshold: 'GPU 90% / 메모리 85%', status: '활성' }, + { rule: '이상 호출 패턴', desc: '비정상적으로 빈번한 Tool 호출 탐지', threshold: '100회/분', status: '활성' }, + { rule: '응답 시간 초과', desc: 'Agent 응답 타임아웃', threshold: '30초', status: '활성' }, +]; + +// ─── 민감명령 승인 ────────────────── +const SENSITIVE_COMMANDS = [ + { command: 'DB 데이터 수정/삭제', level: '높음', approval: '담당자 승인 필수', hitl: true, status: '적용' }, + { command: '모델 배포/롤백', level: '높음', approval: '담당자 승인 필수', hitl: true, status: '적용' }, + { command: '사용자 권한 변경', level: '높음', approval: '관리자 승인 필수', hitl: true, status: '적용' }, + { command: '외부 시스템 연계 호출', level: '중간', approval: '자동 승인 (로그)', hitl: false, status: '적용' }, + { command: '분석 결과 조회', level: '낮음', approval: '자동 승인', hitl: false, status: '적용' }, +]; + +// ─── 에이전트 신원 확인 ────────────────── +const IDENTITY_POLICIES = [ + { policy: '미승인 권한 위임 차단', desc: '명시적으로 승인되지 않은 AI 에이전트로 권한 위임 제한', status: '적용' }, + { policy: 'Agent 간 신원 확인', desc: '협업할 AI 에이전트가 적합한 인증 혹은 신원 보유 중인지 상호 검증', status: '적용' }, + { policy: 'Agent 인증 방식', desc: 'Agent 간 인증은 표준 방식으로 제안, 발주처 간 협의를 통해 최종 선정', status: '적용' }, + { policy: '과도한 호출 방지', desc: 'AI 에이전트의 과도한 호출로 인한 레거시 시스템 마비 방지', status: '적용' }, +]; + +// ─── MCP Tool 권한 ────────────────── +const MCP_PERMISSIONS = [ + { server: 'kcg-db-server', tools: 8, principle: '최소 권한', detail: '조회 Agent는 DB READ만, 쓰기 Agent는 특정 테이블 C/U만', status: '적용' }, + { server: 'kcg-rag-server', tools: 5, principle: '최소 권한', detail: 'RAG 검색만 허용, 인덱스 수정 불가', status: '적용' }, + { server: 'kcg-api-server', tools: 6, principle: '최소 권한', detail: '외부 API 호출은 Rate Limiting + Caching', status: '적용' }, + { server: 'kcg-notify-server', tools: 3, principle: '최소 권한', detail: '알림 발송만 허용, 수신자 목록 수정 불가', status: '적용' }, +]; + +// ─── 감사 로그 ────────────────── +const AUDIT_LOG_SAMPLE = [ + { time: '09:28:15', chain: 'User → LLM → MCP Client → kcg-db-server → DB', agent: '위험도 분석', tool: 'db_read_vessel', result: '성공', latency: '120ms' }, + { time: '09:25:42', chain: 'User → LLM → MCP Client → kcg-rag-server → Milvus', agent: '법령 Q&A', tool: 'search_law', result: '성공', latency: '850ms' }, + { time: '09:20:10', chain: 'User → LLM → MCP Client → kcg-db-server → DB', agent: '단속 이력', tool: 'read_enforcement', result: '성공', latency: '95ms' }, + { time: '09:15:33', chain: 'User → LLM → MCP Client → kcg-db-server → DB', agent: '모선 추론', tool: 'write_parent_result', result: '승인→성공', latency: '230ms' }, + { time: '08:42:00', chain: 'User → LLM → Kill Switch', agent: '데이터 관리', tool: 'db_delete_any', result: '차단', latency: '-' }, +]; + +export function AIAgentSecurityPage() { + const [tab, setTab] = useState('overview'); + + return ( + + + + {/* 탭 */} +
+ {([ + { key: 'overview' as Tab, icon: Activity, label: 'Agent 현황' }, + { key: 'whitelist' as Tab, icon: CheckCircle, label: '화이트리스트 도구' }, + { key: 'killswitch' as Tab, icon: AlertTriangle, label: '자동 중단·승인' }, + { key: 'identity' as Tab, icon: Users, label: 'Agent 신원·권한' }, + { key: 'mcp' as Tab, icon: Layers, label: 'MCP Tool 권한' }, + { key: 'audit' as Tab, icon: FileText, label: '감사 로그' }, + ]).map(t => ( + + ))} +
+ + {/* ── ① Agent 현황 ── */} + {tab === 'overview' && ( +
+
+ {AGENT_KPI.map(k => ( +
+
{k.value}
+
{k.label}
+
+ ))} +
+ +
등록 Agent 목록
+ + + + + + + {AGENTS.map(a => ( + + + + + + + + + ))} +
Agent명유형도구24h 호출최근 호출상태
{a.name}{a.type}{a.tools}{a.calls24h.toLocaleString()}{a.lastCall}{a.status}
+
+
+ )} + + {/* ── ② 화이트리스트 도구 ── */} + {tab === 'whitelist' && ( + +
화이트리스트 기반 도구 관리
+

AI가 사용할 수 있는 도구를 화이트리스트로 지정·관리하여 잘못된 도구를 사용하지 못하도록 구성

+ + + + + + + {WHITELIST_TOOLS.map(t => ( + + + + + + + + + ))} +
Tool ID설명Agent권한MCP Server상태
{t.tool}{t.desc}{t.agent}{t.permission}{t.mcp}{t.status}
+
+ )} + + {/* ── ③ 자동 중단·민감명령 승인 ── */} + {tab === 'killswitch' && ( +
+ +
AI 에이전트 자동 중단 (Kill Switch)
+
+ {KILL_SWITCH_RULES.map(r => ( +
+ +
+
{r.rule}
+
{r.desc}
+
+ 임계값: {r.threshold} + {r.status} +
+ ))} +
+
+ +
민감명령 승인 절차 (Human-in-the-loop)
+

국가안보·사회안전에 영향을 미칠 수 있는 민감한 명령은 전 담당자 승인 절차 마련

+ + + + + + {SENSITIVE_COMMANDS.map(c => ( + + + + + + + + ))} +
명령 유형위험도승인 방식HITL상태
{c.command}{c.level}{c.approval}{c.hitl ? : -}{c.status}
+
+
+ )} + + {/* ── ④ Agent 신원·권한 ── */} + {tab === 'identity' && ( + +
에이전트 권한 위임 차단 및 신원 확인
+
+ {IDENTITY_POLICIES.map(p => ( +
+ +
+
{p.policy}
+
{p.desc}
+
+ {p.status} +
+ ))} +
+
+ )} + + {/* ── ⑤ MCP Tool 권한 ── */} + {tab === 'mcp' && ( + +
MCP Tool 최소 권한 원칙
+

MCP를 통해 연결된 각 도구(Tool)는 최소 권한 원칙에 따라 실행 권한을 부여 (예: 조회 전용 Agent는 DB Update 권한을 가진 MCP Tool 호출 불가)

+ + + + + + {MCP_PERMISSIONS.map(m => ( + + + + + + + + ))} +
MCP ServerTool 수원칙상세상태
{m.server}{m.tools}{m.principle}{m.detail}{m.status}
+
+
Rate Limiting & Caching
+
AI 에이전트의 과도한 호출로 인한 레거시 시스템 마비를 방지하기 위해, MCP 서버 단에서 Rate Limiting(요청 제한) 및 Caching(캐싱) 기능을 필수적으로 구현
+
+
+ )} + + {/* ── ⑥ 감사 로그 ── */} + {tab === 'audit' && ( + +
감사 로그 (Audit Log)
+

모든 MCP Tool 호출 내역(User Request → LLM → MCP Client → MCP Server → Legacy)은 식별 가능한 형태로 기록, A2A 통신 과정의 의사결정 추적성 보장

+ + + + + + {AUDIT_LOG_SAMPLE.map(l => ( + + + + + + + + + ))} +
시각호출 체인AgentTool결과지연
{l.time}{l.chain}{l.agent}{l.tool}{l.result}{l.latency}
+
+ )} +
+ ); +} diff --git a/frontend/src/features/admin/AISecurityPage.tsx b/frontend/src/features/admin/AISecurityPage.tsx new file mode 100644 index 0000000..7a96b44 --- /dev/null +++ b/frontend/src/features/admin/AISecurityPage.tsx @@ -0,0 +1,351 @@ +import { useState } from 'react'; +import { Card, CardContent } from '@shared/components/ui/card'; +import { Badge } from '@shared/components/ui/badge'; +import { PageContainer, PageHeader } from '@shared/components/layout'; +import { getStatusIntent } from '@shared/constants/statusIntent'; +import { + Shield, Database, Brain, Lock, Eye, Activity, + AlertTriangle, CheckCircle, XCircle, FileText, + Server, Layers, Settings, Search, RefreshCw, +} from 'lucide-react'; + +/* + * SER-10: AI 보안 + * + * AI 보안 대책 관리 페이지: + * ① 데이터 수집 보안 ② AI 학습 보안 ③ 시스템 구축운영 보안 + * ④ 입출력 보안 ⑤ 경계보안 ⑥ 취약점 점검 + */ + +type Tab = 'overview' | 'data' | 'training' | 'io' | 'boundary' | 'vulnerability'; + +// ─── 보안 현황 KPI ────────────────── +const SECURITY_KPI = [ + { label: '데이터 수집 보안', value: '정상', score: 95, icon: Database, color: '#10b981' }, + { label: 'AI 학습 보안', value: '정상', score: 92, icon: Brain, color: '#3b82f6' }, + { label: '입출력 필터링', value: '정상', score: 98, icon: Lock, color: '#8b5cf6' }, + { label: '경계 보안', value: '주의', score: 85, icon: Server, color: '#f59e0b' }, + { label: '취약점 점검', value: '정상', score: 90, icon: Search, color: '#06b6d4' }, +]; + +// ─── 데이터 수집 보안 ────────────────── +const DATA_SOURCES = [ + { name: 'AIS 원본 (SNPDB)', provider: '해경', trust: '인증', encryption: 'TLS 1.3', lastAudit: '2026-04-10', status: '정상' }, + { name: 'V-PASS 위치정보', provider: '해수부', trust: '인증', encryption: 'TLS 1.3', lastAudit: '2026-04-09', status: '정상' }, + { name: '기상 데이터', provider: '기상청', trust: '인증', encryption: 'TLS 1.2', lastAudit: '2026-04-08', status: '정상' }, + { name: '위성영상', provider: '해양조사원', trust: '인증', encryption: 'TLS 1.3', lastAudit: '2026-04-07', status: '정상' }, + { name: '법령·판례', provider: '법무부', trust: '인증', encryption: 'TLS 1.2', lastAudit: '2026-04-05', status: '정상' }, + { name: '단속 이력', provider: '해경', trust: '내부', encryption: 'AES-256', lastAudit: '2026-04-10', status: '정상' }, +]; + +const CONTAMINATION_CHECKS = [ + { check: '오염데이터 탐지', desc: 'AI 학습·재학습 시 오염데이터 검사·관리', status: '활성', lastRun: '04-10 09:15' }, + { check: 'RAG 오염 차단', desc: '신규데이터 참조 시 오염데이터 유입 차단·방지', status: '활성', lastRun: '04-10 09:00' }, + { check: '출처 검증', desc: '공신력 있는 출처·배포 정보 구분 제출', status: '활성', lastRun: '04-10 08:30' }, + { check: '악성코드 사전검사', desc: '신뢰 출처 데이터라도 오염 가능, 사전 검사 필요', status: '활성', lastRun: '04-10 08:00' }, +]; + +// ─── AI 학습 보안 ────────────────── +const TRAINING_POLICIES = [ + { policy: '보안등급별 데이터 분류', desc: 'AI시스템 활용목적 및 등급분류에 맞게 기밀·민감·공개등급 데이터 활용', status: '적용' }, + { policy: '사용자별 접근통제', desc: 'AI시스템이 사용자·부서별 권한에 맞는 학습데이터만 사용하도록 세분화', status: '적용' }, + { policy: '비인가자 접근 차단', desc: 'AI가 비인가자에게 기밀·민감등급 데이터를 제공하지 않도록 통제', status: '적용' }, + { policy: '저장소·DB 접근통제', desc: '보관된 학습데이터에 대한 사용자 접근통제', status: '적용' }, + { policy: '최소 접근권한 설계', desc: '사용자, 그룹, 데이터별로 최소 접근권한만 부여하도록 설계', status: '적용' }, + { policy: '다중 보안 인증', desc: '학습데이터 관리자 권한에 대해서는 다중 보안 인증 등 활용 방안 적용', status: '적용' }, + { policy: '오픈소스 모델 신뢰성', desc: '공신력 있는 출처·배포자가 제공하는 AI 모델·라이브러리 사용', status: '적용' }, +]; + +// ─── 입출력 보안 ────────────────── +const IO_FILTERS = [ + { name: '입력 필터링', desc: '민감정보·적대적 공격 문구 포함여부 확인 및 차단', status: '활성', blocked: 23, total: 15420 }, + { name: '출력 필터링', desc: '응답 내 민감정보 노출 차단', status: '활성', blocked: 8, total: 15420 }, + { name: '입력 길이·형식 제한', desc: '공격용 프롬프트 과도 입력 방지', status: '활성', blocked: 5, total: 15420 }, + { name: '요청 속도 제한', desc: '호출 횟수, 동시 처리 요청수, 출력 용량 등 제한', status: '활성', blocked: 12, total: 15420 }, +]; + +// ─── 경계 보안 ────────────────── +const BOUNDARY_ITEMS = [ + { item: 'DMZ·중계서버', desc: 'AI시스템에 접근하는 사용자·시스템 식별 및 통제', status: '적용' }, + { item: '인가 시스템 제한', desc: 'AI시스템이 인가된 내·외부 시스템·데이터만 활용하도록 제한', status: '적용' }, + { item: '권한 부여 제한', desc: 'AI시스템에 과도한 권한 부여 제한', status: '적용' }, + { item: '민감작업 승인절차', desc: '데이터 수정·시스템 제어 등 민감한 작업 수행 시 담당자 검토·승인', status: '적용' }, +]; + +const EXPLAINABILITY = [ + { item: '추론 시각화', desc: '데이터 수정·시스템 제어 시 추론 과정·결과를 설명하거나 판단 근거 시각화', status: '구현' }, + { item: 'Feature Importance', desc: '모델 결정에 영향을 미친 주요 피처 표시', status: '구현' }, + { item: '판단 근거 제공', desc: '위험도 산출 시 기여 요인 설명', status: '구현' }, +]; + +// ─── 취약점 점검 ────────────────── +const VULN_CHECKS = [ + { target: 'AI 모델 서빙 (PyTorch)', version: '2.4.1', lastScan: '2026-04-10', vulns: 0, status: '안전' }, + { target: 'FastAPI 서버', version: '0.115.0', lastScan: '2026-04-10', vulns: 0, status: '안전' }, + { target: 'LangChain (RAG)', version: '0.3.2', lastScan: '2026-04-09', vulns: 1, status: '주의' }, + { target: 'Milvus 벡터DB', version: '2.4.0', lastScan: '2026-04-09', vulns: 0, status: '안전' }, + { target: 'Spring Boot 백엔드', version: '3.4.1', lastScan: '2026-04-10', vulns: 0, status: '안전' }, + { target: 'Node.js (Vite)', version: '22.x', lastScan: '2026-04-10', vulns: 0, status: '안전' }, +]; + +const RECOVERY_PLANS = [ + { plan: '모델 백업 저장소', desc: '이상행위 탐지 시 정상 모델·학습데이터 등으로 복원', status: '활성', detail: 'S3 버전별 백업 24개' }, + { plan: '버전정보 관리', desc: '모델·데이터·설정 버전 이력 추적', status: '활성', detail: 'Git + DVC 연동' }, + { plan: '자동 롤백', desc: '성능 저하 감지 시 이전 안정 버전으로 자동 복구', status: '활성', detail: '임계치 기반 트리거' }, +]; + +export function AISecurityPage() { + const [tab, setTab] = useState('overview'); + + return ( + + + + {/* 탭 */} +
+ {([ + { key: 'overview' as Tab, icon: Activity, label: '보안 현황' }, + { key: 'data' as Tab, icon: Database, label: '데이터 수집 보안' }, + { key: 'training' as Tab, icon: Brain, label: 'AI 학습 보안' }, + { key: 'io' as Tab, icon: Lock, label: '입출력 보안' }, + { key: 'boundary' as Tab, icon: Server, label: '경계 보안' }, + { key: 'vulnerability' as Tab, icon: Search, label: '취약점 점검' }, + ]).map(t => ( + + ))} +
+ + {/* ── ① 보안 현황 ── */} + {tab === 'overview' && ( +
+
+ {SECURITY_KPI.map(k => ( +
+ +
+
+ {k.score} + / 100 +
+
{k.label}
+
+
+ ))} +
+
+ +
보안 정책 준수 현황
+
+ {[ + ['데이터 출처 인증', '6/6 소스 인증 완료', '완료'], + ['오염데이터 검사', '4/4 검사 활성화', '완료'], + ['학습데이터 접근통제', '7/7 정책 적용', '완료'], + ['입출력 필터링', '4/4 필터 활성', '완료'], + ['경계 보안 설정', '4/4 항목 적용', '완료'], + ['취약점 점검', '5/6 안전 (1건 주의)', '주의'], + ['복구 계획', '3/3 활성', '완료'], + ].map(([k, v, s]) => ( +
+ {s === '완료' ? : } + {k} + {v} +
+ ))} +
+
+ +
최근 보안 이벤트
+
+ {[ + ['04-10 09:15', '오염데이터 검사 완료', '정상', '0건 탐지'], + ['04-10 08:42', '입력 필터링 차단', '경고', '공격 패턴 1건 차단'], + ['04-09 14:30', '취약점 스캔 완료', '주의', 'LangChain CVE-2026-1234'], + ['04-09 10:00', '학습데이터 접근 감사', '정상', '비정상 접근 0건'], + ['04-08 16:00', '모델 백업 완료', '정상', 'v2.1.0 → S3'], + ['04-08 09:00', 'RAG 오염 차단 검사', '정상', '0건 탐지'], + ].map(([time, event, level, detail]) => ( +
+ {time} + {event} + {level} + {detail} +
+ ))} +
+
+
+
+ )} + + {/* ── ② 데이터 수집 보안 ── */} + {tab === 'data' && ( +
+ +
데이터 소스 신뢰성 관리
+ + + + + + + {DATA_SOURCES.map(d => ( + + + + + + + + + ))} +
데이터 소스제공기관신뢰등급암호화최근 감사상태
{d.name}{d.provider}{d.trust}{d.encryption}{d.lastAudit}{d.status}
+
+ +
오염데이터 방지 검사
+
+ {CONTAMINATION_CHECKS.map(c => ( +
+ +
+
{c.check}
+
{c.desc}
+
+ {c.status} + 최근: {c.lastRun} +
+ ))} +
+
+
+ )} + + {/* ── ③ AI 학습 보안 ── */} + {tab === 'training' && ( + +
학습 데이터 보안 정책
+
+ {TRAINING_POLICIES.map(p => ( +
+ +
+
{p.policy}
+
{p.desc}
+
+ {p.status} +
+ ))} +
+
+ )} + + {/* ── ④ 입출력 보안 ── */} + {tab === 'io' && ( + +
AI 시스템 입·출력 보안 대책
+ + + + + + + {IO_FILTERS.map(f => ( + + + + + + + + + ))} +
필터설명상태차단총 요청차단율
{f.name}{f.desc}{f.status}{f.blocked}{f.total.toLocaleString()}{(f.blocked / f.total * 100).toFixed(2)}%
+
+ )} + + {/* ── ⑤ 경계 보안 ── */} + {tab === 'boundary' && ( +
+ +
AI 시스템 경계 보안
+
+ {BOUNDARY_ITEMS.map(b => ( +
+ +
+
{b.item}
+
{b.desc}
+
+ {b.status} +
+ ))} +
+
+ +
설명 가능한 AI (XAI)
+
+ {EXPLAINABILITY.map(e => ( +
+ +
+
{e.item}
+
{e.desc}
+
+ {e.status} +
+ ))} +
+
+
+ )} + + {/* ── ⑥ 취약점 점검 ── */} + {tab === 'vulnerability' && ( +
+ +
AI 시스템 소프트웨어·라이브러리 취약점 점검
+ + + + + + {VULN_CHECKS.map(v => ( + + + + + + + + ))} +
대상버전최근 스캔취약점상태
{v.target}{v.version}{v.lastScan}{v.vulns > 0 ? {v.vulns}건 : 0건}{v.status}
+
+ +
AI 시스템 복구 방안
+
+ {RECOVERY_PLANS.map(r => ( +
+ +
+
{r.plan}
+
{r.desc}
+
+ {r.detail} + {r.status} +
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/features/admin/index.ts b/frontend/src/features/admin/index.ts index 0765a29..9eb51ae 100644 --- a/frontend/src/features/admin/index.ts +++ b/frontend/src/features/admin/index.ts @@ -3,3 +3,5 @@ export { SystemConfig } from './SystemConfig'; export { NoticeManagement } from './NoticeManagement'; export { AdminPanel } from './AdminPanel'; export { DataHub } from './DataHub'; +export { AISecurityPage } from './AISecurityPage'; +export { AIAgentSecurityPage } from './AIAgentSecurityPage'; diff --git a/frontend/src/features/ai-operations/LGCNSMLOpsPage.tsx b/frontend/src/features/ai-operations/LGCNSMLOpsPage.tsx new file mode 100644 index 0000000..2f4f515 --- /dev/null +++ b/frontend/src/features/ai-operations/LGCNSMLOpsPage.tsx @@ -0,0 +1,431 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Card, CardContent } from '@shared/components/ui/card'; +import { Badge } from '@shared/components/ui/badge'; +import { PageContainer, PageHeader } from '@shared/components/layout'; +import { getStatusIntent } from '@shared/constants/statusIntent'; +import { + Brain, Database, GitBranch, Activity, Server, Shield, + Settings, Layers, BarChart3, Code, Play, + Zap, FlaskConical, CheckCircle, + Terminal, RefreshCw, Box, +} from 'lucide-react'; + +/* + * LGCNS MLOps 플랫폼 통합 페이지 + * + * LGCNS DAP(Data AI Platform) 기반 MLOps 파이프라인 관리: + * ① 프로젝트 관리 ② 분석환경 생성 및 관리 ③ 모델 관리 ④ Job 실행 관리 + * ⑤ 공통서비스 ⑥ 모니터링 ⑦ Repository + */ + +type Tab = 'project' | 'environment' | 'model' | 'job' | 'common' | 'monitoring' | 'repository'; + +// ─── 프로젝트 관리 ────────────────── +const PROJECTS = [ + { id: 'PRJ-001', name: '불법조업 위험도 예측', owner: '분석팀', status: '활성', models: 5, experiments: 12, updated: '2026-04-10' }, + { id: 'PRJ-002', name: '경비함정 경로추천', owner: '운항팀', status: '활성', models: 3, experiments: 8, updated: '2026-04-09' }, + { id: 'PRJ-003', name: '다크베셀 탐지', owner: '분석팀', status: '활성', models: 2, experiments: 6, updated: '2026-04-08' }, + { id: 'PRJ-004', name: '환적 네트워크 분석', owner: '수사팀', status: '대기', models: 1, experiments: 3, updated: '2026-04-05' }, +]; + +// ─── 분석환경 ────────────────── +const ENVIRONMENTS = [ + { name: 'Jupyter Notebook', icon: Code, type: 'IDE', gpu: 'Blackwell x1', status: '실행중', user: '김분석', created: '04-10' }, + { name: 'RStudio Server', icon: BarChart3, type: 'IDE', gpu: '-', status: '중지', user: '이연구', created: '04-08' }, + { name: 'VS Code Server', icon: Terminal, type: 'IDE', gpu: 'H200 x1', status: '실행중', user: '박개발', created: '04-09' }, + { name: 'TensorBoard', icon: Activity, type: '모니터링', gpu: '-', status: '실행중', user: '김분석', created: '04-10' }, +]; + +const WORKFLOWS = [ + { id: 'WF-012', name: 'AIS 전처리 → LSTM 학습', steps: 5, status: 'running', progress: 60, duration: '2h 15m' }, + { id: 'WF-011', name: '어구분류 피처엔지니어링', steps: 3, status: 'done', progress: 100, duration: '45m' }, + { id: 'WF-010', name: 'GNN 환적탐지 학습', steps: 4, status: 'done', progress: 100, duration: '3h 20m' }, +]; + +// ─── 모델 관리 ────────────────── +const MODELS = [ + { name: '불법조업 위험도 v2.1', framework: 'PyTorch', status: 'DEPLOYED', accuracy: 93.2, version: 'v2.1.0', kpi: 'F1=92.3%', endpoint: '/v1/infer/risk' }, + { name: '경비함정 경로추천 v1.5', framework: 'TensorFlow', status: 'DEPLOYED', accuracy: 89.7, version: 'v1.5.2', kpi: 'F1=88.4%', endpoint: '/v1/infer/patrol' }, + { name: 'Transformer 궤적 v0.9', framework: 'PyTorch', status: 'APPROVED', accuracy: 91.2, version: 'v0.9.0', kpi: 'F1=90.5%', endpoint: '-' }, + { name: 'GNN 환적탐지 v0.3', framework: 'DGL', status: 'TESTING', accuracy: 82.3, version: 'v0.3.0', kpi: 'F1=80.1%', endpoint: '-' }, +]; + +const PARAMETERS = [ + { name: 'learning_rate', type: 'float', default: '0.001', range: '1e-5 ~ 0.1' }, + { name: 'batch_size', type: 'int', default: '64', range: '16 ~ 256' }, + { name: 'epochs', type: 'int', default: '50', range: '10 ~ 200' }, + { name: 'dropout', type: 'float', default: '0.2', range: '0.0 ~ 0.5' }, + { name: 'hidden_dim', type: 'int', default: '256', range: '64 ~ 1024' }, +]; + +// ─── Job 실행 관리 ────────────────── +const JOBS = [ + { id: 'JOB-088', name: 'LSTM 위험도 학습', type: '학습', resource: 'Blackwell x2', status: 'running', progress: 72, started: '04-10 08:00', elapsed: '3h 28m' }, + { id: 'JOB-087', name: 'AIS 피처 추출', type: '전처리', resource: 'CPU 16core', status: 'done', progress: 100, started: '04-10 06:00', elapsed: '1h 45m' }, + { id: 'JOB-086', name: 'GNN 하이퍼파라미터 탐색', type: 'HPS', resource: 'H200 x2', status: 'running', progress: 45, started: '04-10 07:30', elapsed: '2h 10m' }, + { id: 'JOB-085', name: '위험도 모델 배포', type: '배포', resource: 'Blackwell x1', status: 'done', progress: 100, started: '04-09 14:00', elapsed: '15m' }, + { id: 'JOB-084', name: 'SAR 이미지 전처리', type: '전처리', resource: 'CPU 32core', status: 'fail', progress: 34, started: '04-09 10:00', elapsed: '0h 50m' }, +]; + +const PIPELINE_STAGES = [ + { name: '데이터 수집', status: 'done' }, + { name: '전처리', status: 'done' }, + { name: '피처 엔지니어링', status: 'done' }, + { name: '모델 학습', status: 'running' }, + { name: '모델 평가', status: 'pending' }, + { name: '모델 배포', status: 'pending' }, +]; + +// ─── 공통서비스 ────────────────── +const COMMON_SERVICES = [ + { name: 'Feature Store', icon: Database, desc: '피처 저장소', status: '정상', version: 'v3.2', detail: '20개 피처 · 2.4TB' }, + { name: 'Model Registry', icon: GitBranch, desc: '모델 레지스트리', status: '정상', version: 'v2.1', detail: '12개 모델 등록' }, + { name: 'Data Catalog', icon: Layers, desc: '데이터 카탈로그', status: '정상', version: 'v1.5', detail: '48 테이블 · 1.2M rows' }, + { name: 'Experiment Tracker', icon: FlaskConical, desc: '실험 추적', status: '정상', version: 'v4.0', detail: '42개 실험 기록' }, + { name: 'API Gateway', icon: Zap, desc: 'API 게이트웨이', status: '정상', version: 'v3.0', detail: '221 req/s' }, + { name: 'Security Manager', icon: Shield, desc: '보안 관리', status: '정상', version: 'v2.0', detail: 'RBAC + JWT' }, +]; + +// ─── 모니터링 ────────────────── +const GPU_RESOURCES = [ + { name: 'Blackwell #1', usage: 78, mem: '38/48GB', temp: '62°C', job: 'JOB-088' }, + { name: 'Blackwell #2', usage: 52, mem: '25/48GB', temp: '55°C', job: 'JOB-088' }, + { name: 'H200 #1', usage: 85, mem: '68/80GB', temp: '71°C', job: 'JOB-086' }, + { name: 'H200 #2', usage: 45, mem: '36/80GB', temp: '48°C', job: '-' }, +]; + +const SYSTEM_METRICS = [ + { label: '실행중 Job', value: '3', color: '#3b82f6' }, + { label: '대기중 Job', value: '2', color: '#f59e0b' }, + { label: 'GPU 사용률', value: '65%', color: '#10b981' }, + { label: '배포 모델', value: '2', color: '#8b5cf6' }, + { label: 'API 호출/s', value: '221', color: '#06b6d4' }, +]; + +export function LGCNSMLOpsPage() { + const { t } = useTranslation('ai'); + const [tab, setTab] = useState('project'); + + return ( + + + + {/* 탭 */} +
+ {([ + { key: 'project' as Tab, icon: Layers, label: '프로젝트 관리' }, + { key: 'environment' as Tab, icon: Terminal, label: '분석환경 관리' }, + { key: 'model' as Tab, icon: Brain, label: '모델 관리' }, + { key: 'job' as Tab, icon: Play, label: 'Job 실행 관리' }, + { key: 'common' as Tab, icon: Settings, label: '공통서비스' }, + { key: 'monitoring' as Tab, icon: Activity, label: '모니터링' }, + { key: 'repository' as Tab, icon: Database, label: 'Repository' }, + ]).map(t => ( + + ))} +
+ + {/* ── ① 프로젝트 관리 ── */} + {tab === 'project' && ( +
+ +
프로젝트 목록
+ + + + + + {PROJECTS.map(p => ( + + + + + + + + + + ))} +
ID프로젝트명담당상태모델실험수정일
{p.id}{p.name}{p.owner}{p.status}{p.models}{p.experiments}{p.updated}
+
+
+ {[ + { label: '활성 프로젝트', value: 3, icon: Layers, color: '#3b82f6' }, + { label: '총 모델', value: 11, icon: Brain, color: '#8b5cf6' }, + { label: '총 실험', value: 29, icon: FlaskConical, color: '#10b981' }, + { label: '참여 인원', value: 8, icon: Server, color: '#f59e0b' }, + ].map(k => ( +
+ +
{k.value}
{k.label}
+
+ ))} +
+
+ )} + + {/* ── ② 분석환경 관리 ── */} + {tab === 'environment' && ( +
+ +
분석환경 목록
+
+ {ENVIRONMENTS.map(e => ( +
+ + {e.name} + {e.type} + GPU: {e.gpu} + 사용자: {e.user} + {e.status} +
+ ))} +
+
+ +
워크플로우
+
+ {WORKFLOWS.map(w => ( +
+ {w.id} + {w.name} + Steps: {w.steps} +
+
+
+ {w.progress}% + + {w.status === 'running' ? '실행중' : w.status === 'done' ? '완료' : '실패'} + +
+ ))} +
+ +
+ )} + + {/* ── ③ 모델 관리 ── */} + {tab === 'model' && ( +
+ +
등록 모델
+ + + + + + {MODELS.map(m => ( + + + + + + + + + + ))} +
모델명프레임워크상태정확도KPI버전Endpoint
{m.name}{m.framework}{m.status}{m.accuracy}%{m.kpi}{m.version}{m.endpoint}
+
+ +
Parameter 관리
+ + + + + {PARAMETERS.map(p => ( + + + + + + + ))} +
파라미터타입기본값범위
{p.name}{p.type}{p.default}{p.range}
+
+
+ )} + + {/* ── ④ Job 실행 관리 ── */} + {tab === 'job' && ( +
+ +
Job Pipeline
+
+ {PIPELINE_STAGES.map((s, i) => ( +
+
+ {s.status === 'done' && } + {s.status === 'running' && } + {s.name} +
+ {i < PIPELINE_STAGES.length - 1 && } +
+ ))} +
+
+ +
Job 목록
+ + + + + + {JOBS.map(j => ( + + + + + + + + + + ))} +
IDJob명유형리소스진행률상태소요시간
{j.id}{j.name}{j.type}{j.resource} +
+
+
+
+ {j.progress}% +
+
+ + {j.status === 'running' ? '실행중' : j.status === 'done' ? '완료' : '실패'} + + {j.elapsed}
+
+
+ )} + + {/* ── ⑤ 공통서비스 ── */} + {tab === 'common' && ( +
+ {COMMON_SERVICES.map(s => ( + +
+
+ +
+
+
{s.name}
+
{s.desc}
+
+ {s.status} +
+
+
+ 버전{s.version} +
+
+ 상세{s.detail} +
+
+
+ ))} +
+ )} + + {/* ── ⑥ 모니터링 ── */} + {tab === 'monitoring' && ( +
+
+ {SYSTEM_METRICS.map(k => ( +
+
{k.value}
{k.label}
+
+ ))} +
+
+ +
GPU 리소스 현황
+
+ {GPU_RESOURCES.map(g => ( +
+ {g.name} +
+
80 ? 'bg-red-500' : g.usage > 60 ? 'bg-yellow-500' : 'bg-green-500'}`} style={{ width: `${g.usage}%` }} /> +
+ {g.usage}% + {g.mem} + {g.temp} + {g.job} +
+ ))} +
+ + +
서비스 상태
+
+ {[ + { name: 'DAP API Gateway', status: 'ok', rps: 221 }, + { name: 'Model Serving', status: 'ok', rps: 186 }, + { name: 'Feature Store', status: 'ok', rps: 45 }, + { name: 'Experiment Tracker', status: 'ok', rps: 32 }, + { name: 'Job Scheduler', status: 'ok', rps: 15 }, + { name: 'PostgreSQL', status: 'ok', rps: 890 }, + ].map(s => ( +
+
+ {s.name} + {s.rps} req/s +
+ ))} +
+ +
+
+ )} + + {/* ── ⑦ Repository ── */} + {tab === 'repository' && ( +
+ +
소스코드/글로벌 Repository
+
+ {[ + ['kcg-ai-monitoring', 'frontend + backend + prediction 모노레포'], + ['kcg-ml-models', '모델 아카이브 (버전별 weight)'], + ['kcg-data-pipeline', 'ETL 스크립트 + Airflow DAG'], + ['kcg-feature-store', '피처 정의 + 변환 로직'], + ].map(([k, v]) => ( +
+ {k}{v} +
+ ))} +
+
+ +
데이터/분석 Repository
+
+ {[ + ['AIS 원본 데이터', 'SNPDB · 5분 주기 증분 · 1.2TB'], + ['학습 데이터셋', '1,456,200건 · 04-03 갱신'], + ['벡터 DB (Milvus)', '1.2M 문서 · 3.6M 벡터'], + ['모델 Artifact', 'S3 · 24개 버전 · 12.8GB'], + ].map(([k, v]) => ( +
+ {k}{v} +
+ ))} +
+
+
+ )} + + ); +} diff --git a/frontend/src/features/ai-operations/index.ts b/frontend/src/features/ai-operations/index.ts index e1d5f54..ace5a14 100644 --- a/frontend/src/features/ai-operations/index.ts +++ b/frontend/src/features/ai-operations/index.ts @@ -1,4 +1,5 @@ export { AIModelManagement } from './AIModelManagement'; export { MLOpsPage } from './MLOpsPage'; +export { LGCNSMLOpsPage } from './LGCNSMLOpsPage'; export { AIAssistant } from './AIAssistant'; export { LLMOpsPage } from './LLMOpsPage'; diff --git a/frontend/src/lib/i18n/locales/en/ai.json b/frontend/src/lib/i18n/locales/en/ai.json index 4284958..547d032 100644 --- a/frontend/src/lib/i18n/locales/en/ai.json +++ b/frontend/src/lib/i18n/locales/en/ai.json @@ -7,6 +7,10 @@ "title": "MLOps / LLMOps", "desc": "SFR-18/19 | ML & LLM experiment, deployment, monitoring" }, + "lgcnsMlops": { + "title": "LGCNS MLOps", + "desc": "LGCNS DAP-based MLOps pipeline — project, environment, model & job management" + }, "llmOps": { "title": "LLM Operations", "desc": "SFR-20 | Qwen3-8B model management, prompts, inference, RAG, evaluation, security & monitoring" diff --git a/frontend/src/lib/i18n/locales/en/common.json b/frontend/src/lib/i18n/locales/en/common.json index 0fb1bfd..1fce46f 100644 --- a/frontend/src/lib/i18n/locales/en/common.json +++ b/frontend/src/lib/i18n/locales/en/common.json @@ -21,6 +21,7 @@ "reports": "Reports", "aiModel": "AI Model", "mlops": "MLOps", + "lgcnsMlops": "LGCNS MLOps", "llmOps": "LLM Ops", "aiAssistant": "AI Q&A", "dataHub": "Data Hub", @@ -33,7 +34,9 @@ "labelSession": "Label Session", "auditLogs": "Audit Logs", "accessLogs": "Access Logs", - "loginHistory": "Login History" + "loginHistory": "Login History", + "aiSecurity": "AI Security", + "aiAgentSecurity": "AI Agent Security" }, "status": { "active": "Active", diff --git a/frontend/src/lib/i18n/locales/ko/ai.json b/frontend/src/lib/i18n/locales/ko/ai.json index 3dd8bb7..6d43f17 100644 --- a/frontend/src/lib/i18n/locales/ko/ai.json +++ b/frontend/src/lib/i18n/locales/ko/ai.json @@ -7,6 +7,10 @@ "title": "MLOps/LLMOps", "desc": "SFR-18/19 | 기계학습·대규모 언어모델 실험·배포·모니터링 통합" }, + "lgcnsMlops": { + "title": "LGCNS MLOps", + "desc": "LGCNS DAP 기반 MLOps 파이프라인 — 프로젝트·분석환경·모델·Job 관리 통합" + }, "llmOps": { "title": "LLM 운영 관리", "desc": "SFR-20 | Qwen3-8B 모델 관리·프롬프트·추론·RAG·평가·보안·모니터링 통합 운영" diff --git a/frontend/src/lib/i18n/locales/ko/common.json b/frontend/src/lib/i18n/locales/ko/common.json index d646cca..f8b7de0 100644 --- a/frontend/src/lib/i18n/locales/ko/common.json +++ b/frontend/src/lib/i18n/locales/ko/common.json @@ -21,6 +21,7 @@ "reports": "보고서 관리", "aiModel": "AI 모델관리", "mlops": "MLOps", + "lgcnsMlops": "LGCNS MLOps", "llmOps": "LLM 운영", "aiAssistant": "AI 의사결정 지원", "dataHub": "데이터 허브", @@ -33,7 +34,9 @@ "labelSession": "학습 세션", "auditLogs": "감사 로그", "accessLogs": "접근 이력", - "loginHistory": "로그인 이력" + "loginHistory": "로그인 이력", + "aiSecurity": "AI 보안", + "aiAgentSecurity": "AI Agent 보안" }, "status": { "active": "활성", From 45371315ba834b6cde9d9b002e24ae53b1f98ba7 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 13 Apr 2026 11:08:11 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20prediction=20=EC=95=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=EC=A6=98=20=EC=9E=AC=EC=84=A4=EA=B3=84=20+=20?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=20CRUD=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EA=B0=80=EB=93=9C=20=EB=B3=B4=EC=99=84=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/admin/NoticeManagement.tsx | 13 +- frontend/src/features/dashboard/Dashboard.tsx | 4 +- .../detection/DarkVesselDetection.tsx | 4 +- .../src/features/detection/GearDetection.tsx | 4 +- .../src/features/enforcement/EventList.tsx | 24 +- .../src/features/patrol/FleetOptimization.tsx | 4 +- frontend/src/features/patrol/PatrolRoute.tsx | 6 +- .../risk-assessment/EnforcementPlan.tsx | 4 +- .../src/features/risk-assessment/RiskMap.tsx | 4 +- .../src/features/surveillance/LiveMapView.tsx | 4 +- .../src/features/surveillance/MapControl.tsx | 4 +- frontend/src/features/vessel/VesselDetail.tsx | 4 +- frontend/src/lib/map/BaseMap.tsx | 6 + frontend/src/lib/map/hooks/useMapLayers.ts | 8 +- frontend/src/lib/map/index.ts | 2 +- frontend/src/lib/map/layers/index.ts | 2 +- frontend/src/lib/map/layers/static.ts | 66 +- prediction/algorithms/dark_vessel.py | 49 +- prediction/algorithms/transshipment.py | 758 ++++++++++-------- prediction/cache/vessel_store.py | 46 +- prediction/config.py | 4 + prediction/data/monitoring_zones.json | 45 ++ prediction/db/signal_api.py | 174 ++++ prediction/main.py | 4 + prediction/scheduler.py | 19 +- prediction/scripts/diagnostic-snapshot.sh | 326 ++++++++ 26 files changed, 1172 insertions(+), 416 deletions(-) create mode 100644 prediction/data/monitoring_zones.json create mode 100644 prediction/db/signal_api.py create mode 100644 prediction/scripts/diagnostic-snapshot.sh diff --git a/frontend/src/features/admin/NoticeManagement.tsx b/frontend/src/features/admin/NoticeManagement.tsx index d4b1622..002c2ac 100644 --- a/frontend/src/features/admin/NoticeManagement.tsx +++ b/frontend/src/features/admin/NoticeManagement.tsx @@ -4,6 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/ import { Badge } from '@shared/components/ui/badge'; import { Button } from '@shared/components/ui/button'; import { PageContainer, PageHeader } from '@shared/components/layout'; +import { useAuth } from '@/app/auth/AuthContext'; import type { BadgeIntent } from '@lib/theme/variants'; import { Bell, Plus, Edit2, Trash2, Eye, EyeOff, Calendar, @@ -74,6 +75,10 @@ const ROLE_OPTIONS = ['ADMIN', 'OPERATOR', 'ANALYST', 'FIELD', 'VIEWER']; export function NoticeManagement() { const { t } = useTranslation('admin'); + const { hasPermission } = useAuth(); + const canCreate = hasPermission('admin:notices', 'CREATE'); + const canUpdate = hasPermission('admin:notices', 'UPDATE'); + const canDelete = hasPermission('admin:notices', 'DELETE'); const [notices, setNotices] = useState(INITIAL_NOTICES); const [editingId, setEditingId] = useState(null); const [showForm, setShowForm] = useState(false); @@ -146,7 +151,7 @@ export function NoticeManagement() { description={t('notices.desc')} demo actions={ - } @@ -237,10 +242,10 @@ export function NoticeManagement() {
- -
@@ -414,7 +419,7 @@ export function NoticeManagement() {
diff --git a/frontend/src/features/dashboard/Dashboard.tsx b/frontend/src/features/dashboard/Dashboard.tsx index 98f3cd7..670d95a 100644 --- a/frontend/src/features/dashboard/Dashboard.tsx +++ b/frontend/src/features/dashboard/Dashboard.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, useRef, useCallback, memo } from 'react'; import { useTranslation } from 'react-i18next'; -import { BaseMap, STATIC_LAYERS, createMarkerLayer, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map'; +import { BaseMap, createStaticLayers, createMarkerLayer, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map'; import type { HeatPoint, MarkerData } from '@lib/map'; import { AlertTriangle, Ship, Anchor, Eye, Navigation, @@ -187,7 +187,7 @@ function SeaAreaMap() { const mapRef = useRef(null); const buildLayers = useCallback(() => [ - ...STATIC_LAYERS, + ...createStaticLayers(), createHeatmapLayer('threat-heat', THREAT_HEAT as HeatPoint[], { radiusPixels: 22 }), createMarkerLayer('threat-markers', THREAT_MARKERS), ], []); diff --git a/frontend/src/features/detection/DarkVesselDetection.tsx b/frontend/src/features/detection/DarkVesselDetection.tsx index 7d716c9..8dc12dd 100644 --- a/frontend/src/features/detection/DarkVesselDetection.tsx +++ b/frontend/src/features/detection/DarkVesselDetection.tsx @@ -7,7 +7,7 @@ import { Select } from '@shared/components/ui/select'; import { PageContainer, PageHeader } from '@shared/components/layout'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { EyeOff, AlertTriangle, Loader2, Filter } from 'lucide-react'; -import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; +import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import type { MarkerData } from '@lib/map'; import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi'; import { formatDateTime } from '@shared/utils/dateFormat'; @@ -170,7 +170,7 @@ export function DarkVesselDetection() { const mapRef = useRef(null); const buildLayers = useCallback(() => [ - ...STATIC_LAYERS, + ...createStaticLayers(), createRadiusLayer( 'dv-radius', DATA.filter((d) => d.darkScore >= 70).map((d) => ({ diff --git a/frontend/src/features/detection/GearDetection.tsx b/frontend/src/features/detection/GearDetection.tsx index aad29cb..21b1669 100644 --- a/frontend/src/features/detection/GearDetection.tsx +++ b/frontend/src/features/detection/GearDetection.tsx @@ -5,7 +5,7 @@ import { Badge } from '@shared/components/ui/badge'; import { PageContainer, PageHeader } from '@shared/components/layout'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { Anchor, AlertTriangle, Loader2 } from 'lucide-react'; -import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; +import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import type { MarkerData } from '@lib/map'; import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi'; import { formatDate } from '@shared/utils/dateFormat'; @@ -106,7 +106,7 @@ export function GearDetection() { const mapRef = useRef(null); const buildLayers = useCallback(() => [ - ...STATIC_LAYERS, + ...createStaticLayers(), createRadiusLayer( 'gear-radius', DATA.filter(g => g.risk === '고위험').map(g => ({ diff --git a/frontend/src/features/enforcement/EventList.tsx b/frontend/src/features/enforcement/EventList.tsx index 3aa1a4b..5f38f3f 100644 --- a/frontend/src/features/enforcement/EventList.tsx +++ b/frontend/src/features/enforcement/EventList.tsx @@ -20,6 +20,7 @@ import { type AlertLevel as AlertLevelType, getAlertLevelLabel, getAlertLevelInt import { getEventStatusIntent, getEventStatusLabel } from '@shared/constants/eventStatuses'; import { getViolationLabel, getViolationIntent } from '@shared/constants/violationTypes'; import { useSettingsStore } from '@stores/settingsStore'; +import { useAuth } from '@/app/auth/AuthContext'; /* * 이벤트 목록 — SFR-02 공통컴포넌트 적용 @@ -51,6 +52,9 @@ export function EventList() { const { t: tc } = useTranslation('common'); const lang = useSettingsStore((s) => s.language); const navigate = useNavigate(); + const { hasPermission } = useAuth(); + const canAck = hasPermission('enforcement:event-list', 'UPDATE'); + const canCreateEnforcement = hasPermission('enforcement:enforcement-history', 'CREATE'); const { events: storeEvents, rawEvents, @@ -167,9 +171,9 @@ export function EventList() { return (
{isNew && ( - )} @@ -180,14 +184,14 @@ export function EventList() { {isActionable && ( <> - - @@ -196,7 +200,7 @@ export function EventList() { ); }, }, - ], [tc, lang, actionLoading, handleAck, handleFalsePositive, handleCreateEnforcement, navigate]); + ], [tc, lang, actionLoading, handleAck, handleFalsePositive, handleCreateEnforcement, navigate, canAck, canCreateEnforcement]); const [levelFilter, setLevelFilter] = useState(''); const [showUpload, setShowUpload] = useState(false); diff --git a/frontend/src/features/patrol/FleetOptimization.tsx b/frontend/src/features/patrol/FleetOptimization.tsx index c66e7d7..0ab53f4 100644 --- a/frontend/src/features/patrol/FleetOptimization.tsx +++ b/frontend/src/features/patrol/FleetOptimization.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { BaseMap, STATIC_LAYERS, createMarkerLayer, createPolylineLayer, createZoneLayer, useMapLayers, type MapHandle } from '@lib/map'; +import { BaseMap, createStaticLayers, createMarkerLayer, createPolylineLayer, createZoneLayer, useMapLayers, type MapHandle } from '@lib/map'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; import { Button } from '@shared/components/ui/button'; @@ -89,7 +89,7 @@ export function FleetOptimization() { })); return [ - ...STATIC_LAYERS, + ...createStaticLayers(), createZoneLayer('coverage', coverageZones, 30000, 0.12), createMarkerLayer('coverage-labels', coverageLabels), ...routeLayers, diff --git a/frontend/src/features/patrol/PatrolRoute.tsx b/frontend/src/features/patrol/PatrolRoute.tsx index d098708..93e0d5a 100644 --- a/frontend/src/features/patrol/PatrolRoute.tsx +++ b/frontend/src/features/patrol/PatrolRoute.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import type maplibregl from 'maplibre-gl'; -import { BaseMap, STATIC_LAYERS, createMarkerLayer, createPolylineLayer, useMapLayers, type MapHandle } from '@lib/map'; +import { BaseMap, createStaticLayers, createMarkerLayer, createPolylineLayer, useMapLayers, type MapHandle } from '@lib/map'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; import { Button } from '@shared/components/ui/button'; @@ -50,7 +50,7 @@ export function PatrolRoute() { const wps = route?.waypoints ?? []; const buildLayers = useCallback(() => { - if (wps.length === 0) return [...STATIC_LAYERS]; + if (wps.length === 0) return [...createStaticLayers()]; const routeCoords: [number, number][] = wps.map(w => [w.lat, w.lng]); const midMarkers = []; @@ -70,7 +70,7 @@ export function PatrolRoute() { }); return [ - ...STATIC_LAYERS, + ...createStaticLayers(), createPolylineLayer('patrol-route', routeCoords, { color: '#06b6d4', width: 3, opacity: 0.8 }), createMarkerLayer('route-midpoints', midMarkers, '#06b6d4', 500), createMarkerLayer('waypoint-markers', waypointMarkers), diff --git a/frontend/src/features/risk-assessment/EnforcementPlan.tsx b/frontend/src/features/risk-assessment/EnforcementPlan.tsx index b6bae6c..d391e4a 100644 --- a/frontend/src/features/risk-assessment/EnforcementPlan.tsx +++ b/frontend/src/features/risk-assessment/EnforcementPlan.tsx @@ -8,7 +8,7 @@ import { DataTable, type DataColumn } from '@shared/components/common/DataTable' import { getRiskIntent, getStatusIntent } from '@shared/constants/statusIntent'; import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels'; import { Shield, AlertTriangle, Ship, Plus, Calendar, Users, Loader2 } from 'lucide-react'; -import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; +import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import type { MarkerData } from '@lib/map'; import { getEnforcementPlans, type EnforcementPlan as EnforcementPlanApi } from '@/services/enforcement'; import { getEvents, type PredictionEvent } from '@/services/event'; @@ -87,7 +87,7 @@ export function EnforcementPlan() { const mapRef = useRef(null); const buildLayers = useCallback(() => [ - ...STATIC_LAYERS, + ...createStaticLayers(), createRadiusLayer( 'ep-radius-confirmed', PLANS.filter(p => p.status === '확정' || p.status === 'CONFIRMED').map(p => ({ diff --git a/frontend/src/features/risk-assessment/RiskMap.tsx b/frontend/src/features/risk-assessment/RiskMap.tsx index 082deb4..48ede07 100644 --- a/frontend/src/features/risk-assessment/RiskMap.tsx +++ b/frontend/src/features/risk-assessment/RiskMap.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useCallback } from 'react'; -import { BaseMap, STATIC_LAYERS, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map'; +import { BaseMap, createStaticLayers, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map'; import type { HeatPoint } from '@lib/map'; import { Card, CardContent } from '@shared/components/ui/card'; import { Button } from '@shared/components/ui/button'; @@ -169,7 +169,7 @@ export function RiskMap() { const buildLayers = useCallback(() => { if (tab !== 'heatmap') return []; return [ - ...STATIC_LAYERS, + ...createStaticLayers(), createHeatmapLayer('risk-heat', HEAT_POINTS as HeatPoint[], { radiusPixels: 25 }), ]; }, [tab]); diff --git a/frontend/src/features/surveillance/LiveMapView.tsx b/frontend/src/features/surveillance/LiveMapView.tsx index 9b81840..ba828af 100644 --- a/frontend/src/features/surveillance/LiveMapView.tsx +++ b/frontend/src/features/surveillance/LiveMapView.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import maplibregl from 'maplibre-gl'; -import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; +import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import type { MarkerData } from '@lib/map'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; @@ -160,7 +160,7 @@ export function LiveMapView() { // deck.gl 레이어 const buildLayers = useCallback(() => [ - ...STATIC_LAYERS, + ...createStaticLayers(), // 선박 분석 데이터 마커 (riskLevel 기반 색상) createMarkerLayer( 'ais-vessels', diff --git a/frontend/src/features/surveillance/MapControl.tsx b/frontend/src/features/surveillance/MapControl.tsx index 60a0657..3927575 100644 --- a/frontend/src/features/surveillance/MapControl.tsx +++ b/frontend/src/features/surveillance/MapControl.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useCallback } from 'react'; -import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; +import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; import { PageContainer, PageHeader } from '@shared/components/layout'; @@ -229,7 +229,7 @@ export function MapControl() { })); return [ - ...STATIC_LAYERS, + ...createStaticLayers(), createRadiusLayer( 'zone-circles-active', activeZones.map(pz => ({ lat: pz.lat, lng: pz.lng, radius: pz.radiusM, color: pz.color })), diff --git a/frontend/src/features/vessel/VesselDetail.tsx b/frontend/src/features/vessel/VesselDetail.tsx index cd76f31..22491c5 100644 --- a/frontend/src/features/vessel/VesselDetail.tsx +++ b/frontend/src/features/vessel/VesselDetail.tsx @@ -8,7 +8,7 @@ import { Camera, Crosshair, Ruler, CircleDot, Clock, LayoutGrid, Brain, Loader2, ShieldAlert, Shield, EyeOff, FileText, } from 'lucide-react'; -import { BaseMap, STATIC_LAYERS, createZoneLayer, createPolylineLayer, JURISDICTION_AREAS, DEPTH_CONTOURS, useMapLayers, type MapHandle } from '@lib/map'; +import { BaseMap, createStaticLayers, createZoneLayer, createPolylineLayer, JURISDICTION_AREAS, DEPTH_CONTOURS, useMapLayers, type MapHandle } from '@lib/map'; import { formatDateTime } from '@shared/utils/dateFormat'; import { getEvents, type PredictionEvent } from '@/services/event'; import { getAnalysisLatest, getAnalysisHistory, type VesselAnalysis } from '@/services/analysisApi'; @@ -160,7 +160,7 @@ export function VesselDetail() { // 지도 레이어 const buildLayers = useCallback(() => [ - ...STATIC_LAYERS, + ...createStaticLayers(), createZoneLayer('jurisdiction', JURISDICTION_AREAS.map((a) => ({ name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000, })), 80000, 0.05), diff --git a/frontend/src/lib/map/BaseMap.tsx b/frontend/src/lib/map/BaseMap.tsx index 089bb9d..241c55d 100644 --- a/frontend/src/lib/map/BaseMap.tsx +++ b/frontend/src/lib/map/BaseMap.tsx @@ -98,6 +98,12 @@ export const BaseMap = memo(forwardRef(function BaseMap map.on('load', () => { onMapReady?.(map); }); return () => { + // deck.gl overlay를 먼저 정리하여 WebGL 리소스 해제 + if (overlayRef.current) { + try { + overlayRef.current.finalize(); + } catch { /* 이미 해제된 경우 무시 */ } + } map.remove(); mapRef.current = null; overlayRef.current = null; diff --git a/frontend/src/lib/map/hooks/useMapLayers.ts b/frontend/src/lib/map/hooks/useMapLayers.ts index ece299e..d92d39a 100644 --- a/frontend/src/lib/map/hooks/useMapLayers.ts +++ b/frontend/src/lib/map/hooks/useMapLayers.ts @@ -35,7 +35,11 @@ export function useMapLayers( handleRef.current?.overlay?.setProps({ layers: buildLayers() }); }); - return () => cancelAnimationFrame(rafRef.current); + return () => { + cancelAnimationFrame(rafRef.current); + // 언마운트 시 레이어 초기화 — stale WebGL 참조 방지 + try { handleRef.current?.overlay?.setProps({ layers: [] }); } catch { /* finalized */ } + }; }); } @@ -60,6 +64,8 @@ export function useStoreLayerSync( return () => { unsub(); cancelAnimationFrame(rafRef.current); + // 언마운트 시 레이어 초기화 — stale WebGL 참조 방지 + try { handleRef.current?.overlay?.setProps({ layers: [] }); } catch { /* finalized */ } }; // buildLayers는 안정적 참조여야 함 (useCallback으로 감싸거나 모듈 스코프) // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/src/lib/map/index.ts b/frontend/src/lib/map/index.ts index c297e24..77801ac 100644 --- a/frontend/src/lib/map/index.ts +++ b/frontend/src/lib/map/index.ts @@ -14,6 +14,6 @@ export { createPolylineLayer, createHeatmapLayer, createZoneLayer, - EEZ_LAYER, NLL_LAYER, STATIC_LAYERS, + createStaticLayers, } from './layers'; export { useMapLayers, useStoreLayerSync } from './hooks/useMapLayers'; diff --git a/frontend/src/lib/map/layers/index.ts b/frontend/src/lib/map/layers/index.ts index 31f9597..ba2f471 100644 --- a/frontend/src/lib/map/layers/index.ts +++ b/frontend/src/lib/map/layers/index.ts @@ -3,4 +3,4 @@ export { createMarkerLayer, createRadiusLayer, type MarkerData } from './markers export { createPolylineLayer } from './polyline'; export { createHeatmapLayer } from './heatmap'; export { createZoneLayer } from './zones'; -export { EEZ_LAYER, NLL_LAYER, STATIC_LAYERS } from './static'; +export { createEEZStaticLayer, createNLLStaticLayer, createStaticLayers } from './static'; diff --git a/frontend/src/lib/map/layers/static.ts b/frontend/src/lib/map/layers/static.ts index 196bc21..20af288 100644 --- a/frontend/src/lib/map/layers/static.ts +++ b/frontend/src/lib/map/layers/static.ts @@ -1,7 +1,10 @@ /** - * 정적 레이어 싱글턴 - * 모듈 로드 시 1회만 생성 → 모든 페이지에서 같은 인스턴스 재사용 - * deck.gl은 같은 id + 같은 data 참조면 GPU 재전송하지 않음 + * 정적 레이어 팩토리 + * + * 주의: deck.gl Layer 인스턴스는 내부에 WebGL 프로그램/모델 캐시를 보유한다. + * 싱글턴으로 공유하면 페이지 전환 시 이전 WebGL 컨텍스트의 stale 참조가 + * 새 컨텍스트에서 재사용되어 "parameter 1 is not of type 'WebGLProgram'" 에러 발생. + * → 매 호출마다 새 인스턴스 생성. deck.gl이 id 기반 diff로 GPU 전송을 최적화. */ import { PathLayer } from 'deck.gl'; import { EEZ_BOUNDARY, NLL_LINE, EEZ_STYLE, NLL_STYLE } from '../constants'; @@ -20,29 +23,38 @@ function hexToRgba(hex: string, opacity: number): [number, number, number, numbe const EEZ_DATA = [{ path: toPath(EEZ_BOUNDARY) }]; const NLL_DATA = [{ path: toPath(NLL_LINE) }]; -/** EEZ 경계선 — 싱글턴, 리렌더/재생성 없음 */ -export const EEZ_LAYER = new PathLayer({ - id: 'eez-boundary', - data: EEZ_DATA, - getPath: (d: { path: [number, number][] }) => d.path, - getColor: hexToRgba(EEZ_STYLE.color, EEZ_STYLE.opacity), - getWidth: EEZ_STYLE.weight * 2, - widthUnits: 'pixels' as const, - getDashArray: [6, 4], - dashJustified: true, -}); +const EEZ_COLOR = hexToRgba(EEZ_STYLE.color, EEZ_STYLE.opacity); +const NLL_COLOR = hexToRgba(NLL_STYLE.color, NLL_STYLE.opacity); -/** NLL 경계선 — 싱글턴, 리렌더/재생성 없음 */ -export const NLL_LAYER = new PathLayer({ - id: 'nll-line', - data: NLL_DATA, - getPath: (d: { path: [number, number][] }) => d.path, - getColor: hexToRgba(NLL_STYLE.color, NLL_STYLE.opacity), - getWidth: NLL_STYLE.weight * 2, - widthUnits: 'pixels' as const, - getDashArray: [8, 4], - dashJustified: true, -}); +/** EEZ 경계선 레이어 생성 */ +export function createEEZStaticLayer() { + return new PathLayer({ + id: 'eez-boundary', + data: EEZ_DATA, + getPath: (d: { path: [number, number][] }) => d.path, + getColor: EEZ_COLOR, + getWidth: EEZ_STYLE.weight * 2, + widthUnits: 'pixels' as const, + getDashArray: [6, 4], + dashJustified: true, + }); +} -/** 정적 기본 레이어 배열 (EEZ + NLL) */ -export const STATIC_LAYERS = [EEZ_LAYER, NLL_LAYER] as const; +/** NLL 경계선 레이어 생성 */ +export function createNLLStaticLayer() { + return new PathLayer({ + id: 'nll-line', + data: NLL_DATA, + getPath: (d: { path: [number, number][] }) => d.path, + getColor: NLL_COLOR, + getWidth: NLL_STYLE.weight * 2, + widthUnits: 'pixels' as const, + getDashArray: [8, 4], + dashJustified: true, + }); +} + +/** 정적 기본 레이어 배열 (EEZ + NLL) — 매 호출마다 새 인스턴스 */ +export function createStaticLayers() { + return [createEEZStaticLayer(), createNLLStaticLayer()]; +} diff --git a/prediction/algorithms/dark_vessel.py b/prediction/algorithms/dark_vessel.py index 656cbd0..25038c8 100644 --- a/prediction/algorithms/dark_vessel.py +++ b/prediction/algorithms/dark_vessel.py @@ -3,8 +3,8 @@ from typing import Callable, Optional import pandas as pd from algorithms.location import haversine_nm -GAP_SUSPICIOUS_SEC = 1800 # 30분 -GAP_HIGH_SUSPICIOUS_SEC = 3600 # 1시간 +GAP_SUSPICIOUS_SEC = 6000 # 100분 (30분 → 100분 상향: 자연 gap 과탐 감소) +GAP_HIGH_SUSPICIOUS_SEC = 10800 # 3시간 GAP_VIOLATION_SEC = 86400 # 24시간 # 한국 AIS 수신 가능 추정 영역 (한반도 + EEZ + 접속수역 여유) @@ -61,7 +61,7 @@ def is_dark_vessel(df_vessel: pd.DataFrame) -> tuple[bool, int]: return False, 0 max_gap_min = max(g['gap_min'] for g in gaps) - is_dark = max_gap_min >= 30 # 30분 이상 소실 + is_dark = max_gap_min >= (GAP_SUSPICIOUS_SEC / 60) # 상수에서 파생 return is_dark, int(max_gap_min) @@ -218,6 +218,10 @@ def compute_dark_suspicion( history: dict, now_kst_hour: int, classify_zone_fn: Optional[Callable[[float, float], dict]] = None, + ship_kind_code: str = '', + nav_status: str = '', + heading: Optional[float] = None, + last_cog: Optional[float] = None, ) -> tuple[int, list[str], str]: """의도적 AIS OFF 의심 점수 산출. @@ -228,6 +232,10 @@ def compute_dark_suspicion( history: {'count_7d': int, 'count_24h': int} now_kst_hour: 현재 KST 시각 (0~23) classify_zone_fn: (lat, lon) -> dict. gap_start 위치의 zone 판단 + ship_kind_code: 선종 코드 (000020=어선, 000023=화물 등) + nav_status: 항해 상태 텍스트 ("Under way using engine" 등) + heading: 선수 방향 (0~360, signal-batch API) + last_cog: gap 직전 침로 (0~360) Returns: (score, patterns, tier) @@ -314,9 +322,40 @@ def compute_dark_suspicion( score += 10 patterns.append('long_gap') - # 감점: gap 시작 위치가 한국 수신 커버리지 밖 → 자연 gap 가능성 + # P9: 선종별 가중치 (signal-batch API 데이터) + if ship_kind_code == '000020': + # 어선이면서 dark → 불법조업 의도 가능성 + score += 10 + patterns.append('fishing_vessel_dark') + elif ship_kind_code == '000023': + # 화물선은 원양 항해 중 자연 gap 빈번 + score -= 10 + patterns.append('cargo_natural_gap') + + # P10: 항해 상태 기반 의도성 + if nav_status: + status_lower = nav_status.lower() + if 'under way' in status_lower and gap_start_sog > 3.0: + # 항행 중 갑자기 OFF → 의도적 + score += 20 + patterns.append('underway_deliberate_off') + elif 'anchor' in status_lower or 'moored' in status_lower: + # 정박 중 gap → 자연스러움 + score -= 15 + patterns.append('anchored_natural_gap') + + # P11: heading vs COG 불일치 (의도적 방향 전환) + if heading is not None and last_cog is not None: + diff = abs(heading - last_cog) % 360 + if diff > 180: + diff = 360 - diff + if diff > 60: + score += 15 + patterns.append('heading_cog_mismatch') + + # 감점: gap 시작 위치가 한국 수신 커버리지 밖 → 자연 gap 가능성 높음 if not _is_in_kr_coverage(gap_start_lat, gap_start_lon): - score -= 30 + score -= 50 patterns.append('out_of_coverage') score = max(0, min(100, score)) diff --git a/prediction/algorithms/transshipment.py b/prediction/algorithms/transshipment.py index 27cdd36..508bccb 100644 --- a/prediction/algorithms/transshipment.py +++ b/prediction/algorithms/transshipment.py @@ -1,19 +1,21 @@ -"""환적(Transshipment) 의심 선박 탐지 — 서버사이드 O(n log n) 구현. +"""환적(Transshipment) 의심 선박 탐지 — 5단계 필터 파이프라인. -프론트엔드 useKoreaFilters.ts의 O(n²) 근접 탐지를 대체한다. -scipy 미설치 환경을 고려하여 그리드 기반 공간 인덱스를 사용한다. +실무 목표: 일일 10건 미만 고신뢰 의심 건. -알고리즘 개요: -1. 후보 선박 필터: sog < 2kn, 선종 (tanker/cargo/fishing), 외국 해안선 제외 -2. 그리드 셀 기반 근접 쌍 탐지: O(n log n) ← 셀 분할 + 인접 9셀 조회 -3. pair_history dict로 쌍별 최초 탐지 시각 영속화 (호출 간 유지) -4. 60분 이상 지속 근접 시 의심 쌍으로 판정 +5단계 필터: + Stage 1: 이종 쌍 필수 (어선 ↔ 운반선) — shipKindCode 기반 + Stage 2: 감시영역(Monitoring Zone) 내 선박만 대상 + Stage 3: 3단계 패턴 검증 (APPROACH → RENDEZVOUS → DEPARTURE) + Stage 4: 점수 산출 (0~100, 50점 미만 미출력) + Stage 5: 밀집 방폭 (1 운반선 : 최대 1 어선) """ from __future__ import annotations +import json import logging import math +import os from datetime import datetime, timezone from typing import Callable, Optional @@ -24,82 +26,150 @@ from fleet_tracker import GEAR_PATTERN logger = logging.getLogger(__name__) # ────────────────────────────────────────────────────────────── -# 상수 (2026-04-09 재조정 — 베테랑 관점) +# 상수 # ────────────────────────────────────────────────────────────── -SOG_THRESHOLD_KN = 1.0 # 2.0 → 1.0 (완전 정박 수준) -PROXIMITY_DEG = 0.0007 # 0.001 → 0.0007 (~77m, GPS 노이즈 포함한 근접) -SUSPECT_DURATION_MIN = 45 # 60 → 45 (gap tolerance 있음) -PAIR_EXPIRY_MIN = 180 # 120 → 180 -GAP_TOLERANCE_CYCLES = 2 # 신규: 2 사이클까지 active에서 빠져도 리셋 안 함 +SOG_THRESHOLD_KN = 2.0 # 저속 기준 (접현 가능 속도) +PROXIMITY_DEG = 0.002 # ~220m (0.0007 → 0.002 상향: 접현 가능 범위) +APPROACH_DEG = 0.01 # ~1.1km (접근 판정 거리) +RENDEZVOUS_MIN = 90 # 체류 최소 시간 (45 → 90분) +PAIR_EXPIRY_MIN = 240 # 쌍 만료 시간 +GAP_TOLERANCE_CYCLES = 3 # 3 사이클(15분)까지 miss 허용 -# 외국 해안 근접 제외 경계 (레거시 — 관할 필터로 대체됨) -_CN_LON_MAX = 123.5 -_JP_LON_MIN = 130.5 -_TSUSHIMA_LAT_MIN = 33.8 -_TSUSHIMA_LON_MIN = 129.0 - -# 한국 EEZ 관할 수역 (단속 가능 범위) -_KR_EEZ_LAT = (32.0, 39.5) -_KR_EEZ_LON = (124.0, 132.0) - -# 환적 불가능 선종 (여객/군함/유조/도선/예인/수색구조) -_TRANSSHIP_EXCLUDED: frozenset[str] = frozenset({ - 'passenger', 'military', 'tanker', 'pilot', 'tug', 'sar', +# 선종 분류 (shipKindCode 기반) +_FISHING_KINDS = frozenset({'000020'}) +# 운반선: 화물선(000023) + 유조선(000024)만. 000027(기타)은 shipTy로 2차 판정 +_CARRIER_KINDS = frozenset({'000023', '000024'}) +# 환적 불가 선종 (shipTy 텍스트 기반 2차 필터) +_EXCLUDED_SHIP_TY = frozenset({ + 'Tug', 'Pilot Boat', 'Search And Rescue', 'Law Enforcement', + 'AtoN', 'Anti Pollution', 'Passenger', 'Medical Transport', }) -# 그리드 셀 크기 -_GRID_CELL_DEG = PROXIMITY_DEG +# ────────────────────────────────────────────────────────────── +# 감시영역 로드 +# ────────────────────────────────────────────────────────────── + +_ZONES_FILE = os.path.join(os.path.dirname(__file__), '..', 'data', 'monitoring_zones.json') +_TRANSSHIP_ZONES: list[dict] = [] + + +def _load_monitoring_zones() -> None: + global _TRANSSHIP_ZONES + try: + with open(_ZONES_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + _TRANSSHIP_ZONES = [ + z for z in data.get('zones', []) + if z.get('type') == 'TRANSSHIP' and z.get('enabled') + ] + logger.info('loaded %d transship monitoring zones', len(_TRANSSHIP_ZONES)) + except Exception as e: + logger.warning('failed to load monitoring zones: %s', e) + _TRANSSHIP_ZONES = [] + + +_load_monitoring_zones() + + +def _point_in_polygon(lat: float, lon: float, polygon: list[list[float]]) -> bool: + """Ray-casting point-in-polygon. polygon: [[lon, lat], ...]""" + n = len(polygon) + inside = False + j = n - 1 + for i in range(n): + xi, yi = polygon[i][0], polygon[i][1] # lon, lat + xj, yj = polygon[j][0], polygon[j][1] + if ((yi > lat) != (yj > lat)) and (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi): + inside = not inside + j = i + return inside + + +def _is_in_transship_zone(lat: float, lon: float) -> Optional[str]: + """감시영역 내 여부. 해당 zone ID 반환, 미해당 시 None.""" + for zone in _TRANSSHIP_ZONES: + if _point_in_polygon(lat, lon, zone['polygon']): + return zone['id'] + return None + + +# ────────────────────────────────────────────────────────────── +# Stage 1: 이종 쌍 필터 +# ────────────────────────────────────────────────────────────── + +def _classify_vessel_role( + ship_kind_code: str, + ship_ty: str, +) -> str: + """선박 역할 분류: 'FISHING', 'CARRIER', 'EXCLUDED', 'UNKNOWN'""" + if ship_kind_code in _FISHING_KINDS: + return 'FISHING' + if ship_kind_code in _CARRIER_KINDS: + # 화물선/유조선 — shipTy가 예인선/관공선이면 제외 + if ship_ty in _EXCLUDED_SHIP_TY: + return 'EXCLUDED' + return 'CARRIER' + # 000027(기타) / 000028(미분류): shipTy 텍스트로 엄격 판정 + if ship_kind_code in ('000027', '000028'): + if ship_ty == 'Cargo': + return 'CARRIER' + if ship_ty == 'Tanker': + return 'CARRIER' + if ship_ty in _EXCLUDED_SHIP_TY: + return 'EXCLUDED' + # N/A, Vessel, 기타 → UNKNOWN (환적 후보에서 제외) + return 'UNKNOWN' + # 000021(함정), 000022(여객), 000025(관공) → 제외 + if ship_kind_code in ('000021', '000022', '000025'): + return 'EXCLUDED' + return 'UNKNOWN' + + +def _is_transship_pair(role_a: str, role_b: str) -> bool: + """어선 + 운반선 조합만 True.""" + return (role_a == 'FISHING' and role_b == 'CARRIER') or \ + (role_b == 'FISHING' and role_a == 'CARRIER') # ────────────────────────────────────────────────────────────── # 내부 헬퍼 # ────────────────────────────────────────────────────────────── -def _is_near_foreign_coast(lat: float, lon: float) -> bool: - """외국 해안 근처 여부 — 중국/일본/대마도 경계 확인.""" - if lon < _CN_LON_MAX: - return True - if lon > _JP_LON_MIN: - return True - if lat > _TSUSHIMA_LAT_MIN and lon > _TSUSHIMA_LON_MIN: - return True - return False +def _haversine_nm(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + R = 3440.065 + dlat = math.radians(lat2 - lat1) + dlon = math.radians(lon2 - lon1) + a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon/2)**2 + return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) -def _is_in_kr_jurisdiction(lat: float, lon: float) -> bool: - """한국 EEZ 관할 수역 여부 (단속 가능 범위).""" - return (_KR_EEZ_LAT[0] <= lat <= _KR_EEZ_LAT[1] - and _KR_EEZ_LON[0] <= lon <= _KR_EEZ_LON[1]) - - -def _is_candidate_ship_type(vessel_type_a: Optional[str], vessel_type_b: Optional[str]) -> bool: - """환적 후보 선종인지 (명시적 제외만 차단, 미상은 허용).""" - a = (vessel_type_a or '').strip().lower() - b = (vessel_type_b or '').strip().lower() - if a in _TRANSSHIP_EXCLUDED or b in _TRANSSHIP_EXCLUDED: +def _within_proximity(a: dict, b: dict) -> bool: + """PROXIMITY_DEG 이내인지 (위경도 근사).""" + dlat = abs(a['lat'] - b['lat']) + if dlat >= PROXIMITY_DEG: return False - return True + cos_lat = math.cos(math.radians((a['lat'] + b['lat']) / 2.0)) + dlon_scaled = abs(a['lon'] - b['lon']) * cos_lat + return dlon_scaled < PROXIMITY_DEG -def _is_gear_name(name: Optional[str]) -> bool: - """어구 이름 패턴 매칭 — fleet_tracker.GEAR_PATTERN SSOT.""" - if not name: +def _within_approach(a: dict, b: dict) -> bool: + """APPROACH_DEG 이내인지 (~1km).""" + dlat = abs(a['lat'] - b['lat']) + if dlat >= APPROACH_DEG: return False - return bool(GEAR_PATTERN.match(name)) + cos_lat = math.cos(math.radians((a['lat'] + b['lat']) / 2.0)) + dlon_scaled = abs(a['lon'] - b['lon']) * cos_lat + return dlon_scaled < APPROACH_DEG def _cell_key(lat: float, lon: float) -> tuple[int, int]: - """위도/경도를 그리드 셀 인덱스로 변환.""" - return (int(math.floor(lat / _GRID_CELL_DEG)), - int(math.floor(lon / _GRID_CELL_DEG))) + return (int(math.floor(lat / APPROACH_DEG)), + int(math.floor(lon / APPROACH_DEG))) def _build_grid(records: list[dict]) -> dict[tuple[int, int], list[int]]: - """선박 리스트를 그리드 셀로 분류. - - Returns: {(row, col): [record index, ...]} - """ grid: dict[tuple[int, int], list[int]] = {} for idx, rec in enumerate(records): key = _cell_key(rec['lat'], rec['lon']) @@ -109,147 +179,130 @@ def _build_grid(records: list[dict]) -> dict[tuple[int, int], list[int]]: return grid -def _within_proximity(a: dict, b: dict) -> bool: - """두 선박이 PROXIMITY_DEG 이내인지 확인 (위경도 직교 근사).""" - dlat = abs(a['lat'] - b['lat']) - if dlat >= PROXIMITY_DEG: - return False - cos_lat = math.cos(math.radians((a['lat'] + b['lat']) / 2.0)) - dlon_scaled = abs(a['lon'] - b['lon']) * cos_lat - return dlon_scaled < PROXIMITY_DEG - - -def _normalize_type(raw: Optional[str]) -> str: - """선종 문자열 소문자 정규화.""" - if not raw: - return '' - return raw.strip().lower() - - def _pair_key(mmsi_a: str, mmsi_b: str) -> tuple[str, str]: - """MMSI 순서를 정규화하여 중복 쌍 방지.""" return (mmsi_a, mmsi_b) if mmsi_a < mmsi_b else (mmsi_b, mmsi_a) -def _evict_expired_pairs( - pair_history: dict, - now: datetime, -) -> None: - """PAIR_EXPIRY_MIN 이상 갱신 없는 pair_history 항목 제거. +def _is_gear_name(name: Optional[str]) -> bool: + if not name: + return False + return bool(GEAR_PATTERN.match(name)) - 새 구조: {(a,b): {'first_seen': dt, 'last_seen': dt, 'miss_count': int}} - """ - expired = [] - for key, meta in pair_history.items(): - if not isinstance(meta, dict): - # 레거시 구조 (datetime 직접 저장)는 즉시 제거 → 다음 사이클에서 재구성 - expired.append(key) - continue - last_seen = meta.get('last_seen') or meta.get('first_seen') - if last_seen is None: - expired.append(key) - continue - if (now - last_seen).total_seconds() / 60 > PAIR_EXPIRY_MIN: - expired.append(key) - for key in expired: - del pair_history[key] + +# ────────────────────────────────────────────────────────────── +# Stage 4: 점수 산출 +# ────────────────────────────────────────────────────────────── + +def _score_pair( + meta: dict, + now: datetime, + is_permitted_fn: Optional[Callable[[str], bool]], + now_kst_hour: int, + zone_id: Optional[str], + vessel_info_a: dict, + vessel_info_b: dict, +) -> Optional[dict]: + """환적 의심 점수 산출. 50점 미만이면 None.""" + phase = meta.get('phase', 'APPROACH') + pair = meta.get('pair') + if not pair: + return None + + # 필수: RENDEZVOUS 90분 이상 + rendezvous_start = meta.get('rendezvous_start') + if rendezvous_start is None: + return None + rendezvous_min = (now - rendezvous_start).total_seconds() / 60 + if rendezvous_min < RENDEZVOUS_MIN: + return None + + score = 0 + + # 3단계 패턴 완성도 + has_approach = meta.get('approach_detected', False) + has_departure = meta.get('departure_detected', False) + if has_approach and has_departure: + score += 35 + elif has_departure or has_approach: + score += 25 + else: + score += 15 + + # 어선 식별 — pair에서 누가 어선이고 누가 운반선인지 + role_a = meta.get('role_a', 'UNKNOWN') + role_b = meta.get('role_b', 'UNKNOWN') + fishing_mmsi = pair[0] if role_a == 'FISHING' else pair[1] + carrier_mmsi = pair[1] if role_a == 'FISHING' else pair[0] + + # 운반선 무허가 + if is_permitted_fn is not None: + try: + if not is_permitted_fn(carrier_mmsi): + score += 15 + except Exception: + pass + + # 야간 (20:00~04:00 KST) + if now_kst_hour >= 20 or now_kst_hour < 4: + score += 10 + + # 수역 가점 (감시영역 종류에 따라) + if zone_id: + if 'EEZ' in zone_id.upper() or '001' in zone_id: + score += 15 + else: + score += 10 + + # 체류 시간 bonus + if rendezvous_min >= 180: + score += 10 + elif rendezvous_min >= 120: + score += 5 + + # heading 병행 판정 (향후 detail API 데이터) + heading_a = vessel_info_a.get('heading') + heading_b = vessel_info_b.get('heading') + if heading_a is not None and heading_b is not None: + diff = abs(heading_a - heading_b) + if diff > 180: + diff = 360 - diff + if diff < 20: # 평행 = 접현 가능 + score += 10 + + score = max(0, min(100, score)) + + if score < 50: + return None + + if score >= 70: + severity = 'CRITICAL' + else: + severity = 'HIGH' + + lat = meta.get('last_lat', 0) + lon = meta.get('last_lon', 0) + + return { + 'pair_a': pair[0], + 'pair_b': pair[1], + 'fishing_mmsi': fishing_mmsi, + 'carrier_mmsi': carrier_mmsi, + 'duration_min': int(rendezvous_min), + 'severity': severity, + 'score': score, + 'lat': lat, + 'lon': lon, + 'zone_id': zone_id, + 'phase': phase, + 'has_approach': has_approach, + 'has_departure': has_departure, + } # ────────────────────────────────────────────────────────────── # 공개 API # ────────────────────────────────────────────────────────────── -def _score_pair( - pair: tuple[str, str], - meta: dict, - lat: float, - lon: float, - cog_a: Optional[float], - cog_b: Optional[float], - vessel_info_a: dict, - vessel_info_b: dict, - is_permitted_fn: Optional[Callable[[str], bool]], - now_kst_hour: int, - zone_code: Optional[str], - now: datetime, -) -> Optional[dict]: - """환적 의심 pair에 대해 점수 산출 + severity 반환. - - 필수 조건 실패 시 None. WATCH 이상이면 dict 반환. - """ - # 필수 1: 한국 관할 수역 - if not _is_in_kr_jurisdiction(lat, lon): - return None - # 필수 2: 선종 필터 - if not _is_candidate_ship_type( - vessel_info_a.get('vessel_type'), - vessel_info_b.get('vessel_type'), - ): - return None - # 필수 3: 어구 제외 - if _is_gear_name(vessel_info_a.get('name')) or _is_gear_name(vessel_info_b.get('name')): - return None - # 필수 4: 지속 시간 - first_seen = meta.get('first_seen') - if first_seen is None: - return None - duration_min = int((now - first_seen).total_seconds() / 60) - if duration_min < SUSPECT_DURATION_MIN: - return None - - score = 40 # base - - # 야간 가점 (KST 20:00~04:00) - if now_kst_hour >= 20 or now_kst_hour < 4: - score += 15 - - # 무허가 가점 - if is_permitted_fn is not None: - try: - if not is_permitted_fn(pair[0]) or not is_permitted_fn(pair[1]): - score += 20 - except Exception: - pass - - # COG 편차 (같은 방향 아니면 가점 — 나란히 가는 선단 배제) - if cog_a is not None and cog_b is not None: - try: - diff = abs(float(cog_a) - float(cog_b)) - if diff > 180: - diff = 360 - diff - if diff > 45: - score += 20 - except Exception: - pass - - # 지속 길이 추가 가점 - if duration_min >= 90: - score += 20 - - # 영해/접속수역 추가 가점 - if zone_code in ('TERRITORIAL_SEA', 'CONTIGUOUS_ZONE'): - score += 15 - - if score >= 90: - severity = 'CRITICAL' - elif score >= 70: - severity = 'HIGH' - elif score >= 50: - severity = 'WATCH' - else: - return None - - return { - 'pair_a': pair[0], - 'pair_b': pair[1], - 'duration_min': duration_min, - 'severity': severity, - 'score': score, - 'lat': lat, - 'lon': lon, - } - - def detect_transshipment( df: pd.DataFrame, pair_history: dict, @@ -258,207 +311,226 @@ def detect_transshipment( classify_zone_fn: Optional[Callable[[float, float], dict]] = None, now_kst_hour: int = 0, ) -> list[dict]: - """환적 의심 쌍 탐지 (점수 기반, 베테랑 관점 필터). + """환적 의심 쌍 탐지 — 5단계 필터 파이프라인. Args: - df: 선박 위치 DataFrame. - 필수 컬럼: mmsi, lat, lon, sog - 선택 컬럼: cog - pair_history: {(a,b): {'first_seen', 'last_seen', 'miss_count'}} - get_vessel_info: callable(mmsi) -> {'name', 'vessel_type', ...} - is_permitted: callable(mmsi) -> bool - classify_zone_fn: callable(lat, lon) -> dict (zone 판정) + df: 선박 위치 DataFrame (mmsi, lat, lon, sog 필수) + pair_history: 호출 간 유지되는 쌍 상태 dict + get_vessel_info: mmsi → {ship_kind_code, ship_ty, name, heading, ...} + is_permitted: mmsi → bool (허가 어선 여부) + classify_zone_fn: (lat, lon) → {zone, ...} now_kst_hour: 현재 KST 시각 (0~23) Returns: - list[dict] — severity 'CRITICAL'/'HIGH'/'WATCH' 포함 의심 쌍 + list[dict] — 의심 쌍 (score >= 50만) """ if df.empty: return [] required_cols = {'mmsi', 'lat', 'lon', 'sog'} - missing = required_cols - set(df.columns) - if missing: - logger.error('detect_transshipment: missing required columns: %s', missing) + if required_cols - set(df.columns): return [] now = datetime.now(timezone.utc) - # ── 1. 후보 선박 필터 (SOG < 1.0) ───────────────────────── - candidate_mask = df['sog'] < SOG_THRESHOLD_KN - candidates = df[candidate_mask].copy() - - if candidates.empty: - _evict_expired_pairs(pair_history, now) - return [] - - # 외국 해안 근처 제외 (1차 필터) - coast_mask = candidates.apply( - lambda row: not _is_near_foreign_coast(row['lat'], row['lon']), - axis=1, - ) - candidates = candidates[coast_mask] - + # ── Stage 1+2: 후보 필터 ────────────────────────────── + # SOG < 2kn 선박만 + candidates = df[df['sog'] < SOG_THRESHOLD_KN].copy() if len(candidates) < 2: - _evict_expired_pairs(pair_history, now) + _evict_expired(pair_history, now) return [] - has_cog = 'cog' in candidates.columns - cols = ['mmsi', 'lat', 'lon'] - if has_cog: - cols.append('cog') - records = candidates[cols].to_dict('records') - for rec in records: - rec['mmsi'] = str(rec['mmsi']) + # 선종 + 감시영역 필터 → 유효 레코드만 + records: list[dict] = [] + for _, row in candidates.iterrows(): + mmsi = str(row['mmsi']) + lat, lon = float(row['lat']), float(row['lon']) - # ── 2. 그리드 기반 근접 쌍 탐지 (77m) ─────────────────── - grid = _build_grid(records) + # Stage 2: 감시영역 내 여부 + zone_id = _is_in_transship_zone(lat, lon) + if zone_id is None: + continue + + # Stage 1: 선종 분류 + info = get_vessel_info(mmsi) if get_vessel_info else {} + kind = info.get('ship_kind_code', '') + ship_ty = info.get('ship_ty', info.get('vessel_type', '')) + role = _classify_vessel_role(kind, ship_ty) + if role in ('EXCLUDED', 'UNKNOWN'): + continue + + # 어구 신호명 제외 + if _is_gear_name(info.get('name')): + continue + + rec = { + 'mmsi': mmsi, 'lat': lat, 'lon': lon, + 'role': role, 'zone_id': zone_id, + 'cog': float(row.get('cog', 0)), + } + records.append(rec) + + if len(records) < 2: + _evict_expired(pair_history, now) + return [] + + # 역할별 분리 + fishing_recs = [r for r in records if r['role'] == 'FISHING'] + carrier_recs = [r for r in records if r['role'] == 'CARRIER'] + + if not fishing_recs or not carrier_recs: + _evict_expired(pair_history, now) + return [] + + # ── 그리드 기반 근접 쌍 탐지 (어선 × 운반선만) ────── + carrier_grid = _build_grid(carrier_recs) active_pairs: dict[tuple[str, str], dict] = {} - def _try_add_pair(a_rec, b_rec): - if not _within_proximity(a_rec, b_rec): - return - key = _pair_key(a_rec['mmsi'], b_rec['mmsi']) - # 중점 좌표 (점수 산출용) - mid_lat = (a_rec['lat'] + b_rec['lat']) / 2.0 - mid_lon = (a_rec['lon'] + b_rec['lon']) / 2.0 - active_pairs[key] = { - 'lat': mid_lat, 'lon': mid_lon, - 'cog_a': a_rec.get('cog'), 'cog_b': b_rec.get('cog'), - # mmsi_a < mmsi_b 순서로 정렬되었으므로 cog도 맞춰 정렬 필요 - 'mmsi_a': a_rec['mmsi'], 'mmsi_b': b_rec['mmsi'], - } + for f_rec in fishing_recs: + f_cell = _cell_key(f_rec['lat'], f_rec['lon']) + # 인접 9셀 탐색 + for dr in (-1, 0, 1): + for dc in (-1, 0, 1): + neighbor = (f_cell[0] + dr, f_cell[1] + dc) + if neighbor not in carrier_grid: + continue + for ci in carrier_grid[neighbor]: + c_rec = carrier_recs[ci] + is_close = _within_proximity(f_rec, c_rec) + is_approaching = _within_approach(f_rec, c_rec) if not is_close else False - for (row, col), indices in grid.items(): - for i in range(len(indices)): - for j in range(i + 1, len(indices)): - _try_add_pair(records[indices[i]], records[indices[j]]) - for dr, dc in ((0, 1), (1, -1), (1, 0), (1, 1)): - neighbor_key = (row + dr, col + dc) - if neighbor_key not in grid: - continue - for ai in indices: - for bi in grid[neighbor_key]: - _try_add_pair(records[ai], records[bi]) + if not is_close and not is_approaching: + continue - # ── 3. pair_history 갱신 (gap tolerance) ───────────────── - active_keys = set(active_pairs.keys()) + key = _pair_key(f_rec['mmsi'], c_rec['mmsi']) + mid_lat = (f_rec['lat'] + c_rec['lat']) / 2 + mid_lon = (f_rec['lon'] + c_rec['lon']) / 2 - # 활성 쌍 → 등록/갱신 - for pair in active_keys: - if pair not in pair_history or not isinstance(pair_history[pair], dict): - pair_history[pair] = { - 'first_seen': now, - 'last_seen': now, + active_pairs[key] = { + 'is_close': is_close, + 'is_approaching': is_approaching, + 'lat': mid_lat, 'lon': mid_lon, + 'zone_id': f_rec['zone_id'], + 'role_a': 'FISHING' if key[0] == f_rec['mmsi'] else 'CARRIER', + 'role_b': 'CARRIER' if key[0] == f_rec['mmsi'] else 'FISHING', + } + + # ── Stage 3: pair_history 상태머신 갱신 ───────────── + for key, loc in active_pairs.items(): + meta = pair_history.get(key) + if meta is None or not isinstance(meta, dict): + # 신규 쌍 + meta = { + 'pair': key, + 'phase': 'APPROACH' if loc['is_approaching'] else 'RENDEZVOUS', + 'approach_detected': loc['is_approaching'], + 'rendezvous_start': now if loc['is_close'] else None, + 'departure_detected': False, 'miss_count': 0, + 'last_lat': loc['lat'], + 'last_lon': loc['lon'], + 'zone_id': loc['zone_id'], + 'role_a': loc['role_a'], + 'role_b': loc['role_b'], } - else: - pair_history[pair]['last_seen'] = now - pair_history[pair]['miss_count'] = 0 + pair_history[key] = meta + continue - # 비활성 쌍 → miss_count++ , GAP_TOLERANCE 초과 시 삭제 + meta['miss_count'] = 0 + meta['last_lat'] = loc['lat'] + meta['last_lon'] = loc['lon'] + + if loc['is_close']: + if meta['phase'] == 'APPROACH': + # 접근 → 체류 전환 + meta['phase'] = 'RENDEZVOUS' + meta['approach_detected'] = True + meta['rendezvous_start'] = meta.get('rendezvous_start') or now + elif meta['phase'] == 'DEPARTURE': + # 분리 후 재접근 → 체류 재개 + meta['phase'] = 'RENDEZVOUS' + # RENDEZVOUS 상태 유지 + if meta.get('rendezvous_start') is None: + meta['rendezvous_start'] = now + + elif loc['is_approaching']: + if meta['phase'] == 'RENDEZVOUS': + # 체류 중 이격 시작 → 분리 단계 + meta['phase'] = 'DEPARTURE' + meta['departure_detected'] = True + elif meta['phase'] == 'APPROACH': + meta['approach_detected'] = True + + # 비활성 쌍 miss_count++ for key in list(pair_history.keys()): - if key in active_keys: + if key in active_pairs: continue meta = pair_history[key] if not isinstance(meta, dict): del pair_history[key] continue + meta['miss_count'] = meta.get('miss_count', 0) + 1 + + # 체류 중이던 쌍이 miss → 분리 가능 + if meta.get('phase') == 'RENDEZVOUS' and meta['miss_count'] >= 2: + meta['phase'] = 'DEPARTURE' + meta['departure_detected'] = True + if meta['miss_count'] > GAP_TOLERANCE_CYCLES: del pair_history[key] - # 만료 정리 - _evict_expired_pairs(pair_history, now) + _evict_expired(pair_history, now) - # ── 4. 점수 기반 의심 쌍 판정 ───────────────────────────── - suspects: list[dict] = [] - rejected_jurisdiction = 0 - rejected_ship_type = 0 - rejected_gear = 0 - rejected_duration = 0 - - for pair, meta in pair_history.items(): + # ── Stage 4+5: 점수 산출 + 밀집 방폭 ──────────────── + raw_suspects: list[dict] = [] + for key, meta in pair_history.items(): if not isinstance(meta, dict): continue - first_seen = meta.get('first_seen') - if first_seen is None: - continue - # active_pairs에 있으면 해당 사이클 좌표·cog 사용, 없으면 이전 값 재사용 (miss 중) - loc_meta = active_pairs.get(pair) - if loc_meta is not None: - lat = loc_meta['lat'] - lon = loc_meta['lon'] - # mmsi_a, mmsi_b 순서를 pair 순서에 맞춤 - if loc_meta['mmsi_a'] == pair[0]: - cog_a, cog_b = loc_meta.get('cog_a'), loc_meta.get('cog_b') - else: - cog_a, cog_b = loc_meta.get('cog_b'), loc_meta.get('cog_a') - meta['last_lat'] = lat - meta['last_lon'] = lon - meta['last_cog_a'] = cog_a - meta['last_cog_b'] = cog_b - else: - lat = meta.get('last_lat') - lon = meta.get('last_lon') - cog_a = meta.get('last_cog_a') - cog_b = meta.get('last_cog_b') - if lat is None or lon is None: - continue - - # 선박 정보 조회 - info_a = get_vessel_info(pair[0]) if get_vessel_info else {} - info_b = get_vessel_info(pair[1]) if get_vessel_info else {} - - # 짧게 pre-check (로깅용) - if not _is_in_kr_jurisdiction(lat, lon): - rejected_jurisdiction += 1 - continue - if not _is_candidate_ship_type(info_a.get('vessel_type'), info_b.get('vessel_type')): - rejected_ship_type += 1 - continue - if _is_gear_name(info_a.get('name')) or _is_gear_name(info_b.get('name')): - rejected_gear += 1 - continue - - duration_min = int((now - first_seen).total_seconds() / 60) - if duration_min < SUSPECT_DURATION_MIN: - rejected_duration += 1 - continue - - zone_code = None - if classify_zone_fn is not None: - try: - zone_code = classify_zone_fn(lat, lon).get('zone') - except Exception: - pass + info_a = get_vessel_info(key[0]) if get_vessel_info else {} + info_b = get_vessel_info(key[1]) if get_vessel_info else {} scored = _score_pair( - pair, meta, lat, lon, cog_a, cog_b, - info_a, info_b, is_permitted, - now_kst_hour, zone_code, now, + meta, now, is_permitted, now_kst_hour, + meta.get('zone_id'), info_a, info_b, ) if scored is not None: - suspects.append(scored) + raw_suspects.append(scored) - tier_counts = {'CRITICAL': 0, 'HIGH': 0, 'WATCH': 0} + # Stage 5: 밀집 방폭 — 1 운반선 : 최대 1 어선 (최고 점수) + carrier_best: dict[str, dict] = {} + for s in raw_suspects: + carrier = s['carrier_mmsi'] + if carrier not in carrier_best or s['score'] > carrier_best[carrier]['score']: + carrier_best[carrier] = s + + suspects = list(carrier_best.values()) + + # 로그 + tier_counts = {'CRITICAL': 0, 'HIGH': 0} for s in suspects: tier_counts[s['severity']] = tier_counts.get(s['severity'], 0) + 1 logger.info( - 'transshipment detection: pairs=%d (critical=%d, high=%d, watch=%d, ' - 'rejected_jurisdiction=%d, rejected_ship_type=%d, rejected_gear=%d, ' - 'rejected_duration=%d, candidates=%d)', - len(suspects), - tier_counts.get('CRITICAL', 0), - tier_counts.get('HIGH', 0), - tier_counts.get('WATCH', 0), - rejected_jurisdiction, - rejected_ship_type, - rejected_gear, - rejected_duration, - len(candidates), + 'transshipment: pairs=%d (critical=%d, high=%d), ' + 'candidates: fishing=%d carrier=%d, active_pairs=%d, history=%d', + len(suspects), tier_counts.get('CRITICAL', 0), tier_counts.get('HIGH', 0), + len(fishing_recs), len(carrier_recs), len(active_pairs), len(pair_history), ) return suspects + + +def _evict_expired(pair_history: dict, now: datetime) -> None: + """PAIR_EXPIRY_MIN 이상 갱신 없는 쌍 제거.""" + expired = [] + for key, meta in pair_history.items(): + if not isinstance(meta, dict): + expired.append(key) + continue + miss = meta.get('miss_count', 0) + if miss > GAP_TOLERANCE_CYCLES: + expired.append(key) + for key in expired: + del pair_history[key] diff --git a/prediction/cache/vessel_store.py b/prediction/cache/vessel_store.py index 7ba95da..6f30112 100644 --- a/prediction/cache/vessel_store.py +++ b/prediction/cache/vessel_store.py @@ -254,7 +254,12 @@ class VesselStore: mmsi_list = list(self._tracks.keys()) try: info = snpdb.fetch_static_info(mmsi_list) - self._static_info.update(info) + # SNPDB 필드(name, vessel_type, length, width)만 갱신하되 + # signal-batch에서 추가한 필드(ship_kind_code, status, heading 등)는 보존 + for mmsi, snpdb_info in info.items(): + existing = self._static_info.get(mmsi, {}) + existing.update(snpdb_info) # SNPDB 필드 덮어쓰기 (signal-batch 필드는 유지) + self._static_info[mmsi] = existing self._static_refreshed_at = now logger.info('static info refreshed: %d vessels', len(info)) except Exception as e: @@ -285,6 +290,45 @@ class VesselStore: except Exception as e: logger.error('fetch_permit_mmsis failed: %s', e) + # ------------------------------------------------------------------ + # Signal-batch API 정적정보 보강 + # ------------------------------------------------------------------ + + def enrich_from_signal_api(self, minutes: int = 10) -> None: + """signal-batch recent-positions-detail API에서 정적정보 보강. + + shipKindCode, status, heading, draught 등 SNPDB에 없는 필드를 수집하여 + _static_info에 merge. 기존 SNPDB 정적정보(name, vessel_type 등)는 유지. + + Args: + minutes: API 조회 시간 범위 (기본 10분, 초기 로드 시 120분) + """ + from db.signal_api import signal_client, _KR_WATERS_POLYGON + + try: + raw = signal_client.fetch_recent_detail( + minutes=minutes, + polygon=_KR_WATERS_POLYGON, + ) + if not raw: + return + + parsed = signal_client.parse_static_info(raw) + enriched = 0 + for mmsi, new_info in parsed.items(): + existing = self._static_info.get(mmsi, {}) + # 신규 필드만 merge (기존 값 덮어쓰기) + existing.update(new_info) + self._static_info[mmsi] = existing + enriched += 1 + + logger.info( + 'signal-batch enrich: %d vessels enriched (minutes=%d, api_total=%d)', + enriched, minutes, len(raw), + ) + except Exception as e: + logger.warning('enrich_from_signal_api failed: %s', e) + # ------------------------------------------------------------------ # Analysis target selection # ------------------------------------------------------------------ diff --git a/prediction/config.py b/prediction/config.py index 9c3498b..c308e85 100644 --- a/prediction/config.py +++ b/prediction/config.py @@ -36,6 +36,10 @@ class Settings(BaseSettings): MMSI_PREFIX: str = '412' MIN_TRAJ_POINTS: int = 100 + # signal-batch API (gc-signal-batch — 정적정보 보강용) + SIGNAL_BATCH_URL: str = 'http://192.168.1.18:18090/signal-batch' + SIGNAL_BATCH_TIMEOUT: int = 60 + # Ollama (LLM) OLLAMA_BASE_URL: str = 'http://localhost:11434' OLLAMA_MODEL: str = 'qwen3:14b' # CPU-only: 14b 권장, GPU 있으면 32b diff --git a/prediction/data/monitoring_zones.json b/prediction/data/monitoring_zones.json new file mode 100644 index 0000000..e70b420 --- /dev/null +++ b/prediction/data/monitoring_zones.json @@ -0,0 +1,45 @@ +{ + "description": "해양 감시영역 정의 — 환적/불법조업 탐지 범위. 향후 DB 테이블로 이관 + 프론트엔드 운영자 편집 가능.", + "zones": [ + { + "id": "TS-001", + "name": "서해 EEZ 경계 (환적 고위험)", + "type": "TRANSSHIP", + "description": "서해 EEZ 외측 ~ 중국 근해 접경. 중국 어선-운반선 환적 주요 발생 해역.", + "polygon": [[124.0, 33.0], [125.8, 33.0], [125.8, 37.5], [124.0, 37.5], [124.0, 33.0]], + "enabled": true + }, + { + "id": "TS-002", + "name": "남해 외해 (환적 관심)", + "type": "TRANSSHIP", + "description": "제주 남방 ~ 동중국해 접경. 원양 운반선 환적 경유 해역.", + "polygon": [[125.0, 31.5], [130.0, 31.5], [130.0, 33.5], [125.0, 33.5], [125.0, 31.5]], + "enabled": true + }, + { + "id": "TS-003", + "name": "동해 북방 (환적 관심)", + "type": "TRANSSHIP", + "description": "동해 중부 ~ 일본해 경계. 오징어잡이 환적 발생 해역.", + "polygon": [[129.0, 36.0], [131.5, 36.0], [131.5, 38.5], [129.0, 38.5], [129.0, 36.0]], + "enabled": true + }, + { + "id": "FZ-001", + "name": "서해 특정어업수역", + "type": "FISHING", + "description": "한중어업협정 특정어업수역. 불법조업 탐지 대상.", + "polygon": [[124.0, 34.5], [126.0, 34.5], [126.0, 37.0], [124.5, 37.0], [124.0, 36.0], [124.0, 34.5]], + "enabled": true + }, + { + "id": "FZ-002", + "name": "제주 남방 수역", + "type": "FISHING", + "description": "제주 남방 EEZ 내 불법조업 빈발 해역.", + "polygon": [[125.5, 32.0], [127.5, 32.0], [127.5, 33.2], [125.5, 33.2], [125.5, 32.0]], + "enabled": true + } + ] +} diff --git a/prediction/db/signal_api.py b/prediction/db/signal_api.py new file mode 100644 index 0000000..9ed667e --- /dev/null +++ b/prediction/db/signal_api.py @@ -0,0 +1,174 @@ +"""signal-batch API 클라이언트 — gc-signal-batch(192.168.1.18:18090) HTTP 연동. + +데이터 소스: S&P Global AIS API → gc-signal-batch L0 캐시(Caffeine, 120분 TTL) +용도: 정적정보 보강 (shipKindCode, status, heading, draught 등) +궤적 소스는 SNPDB 유지 — 이 모듈은 정적정보 전용. + +호출 전략: + - 초기 로드: minutes=120 (L0 캐시 전체, 412* ~3,150척) + - 5분 주기 보강: minutes=10 (직전 2사이클분, 412* ~728척) + - gc-signal-batch 수집 주기: 매 분 :45초 → 호출 시점 :50초 이후 권장 +""" + +import logging +from typing import Optional + +import httpx + +from config import settings + +logger = logging.getLogger(__name__) + +_KR_WATERS_POLYGON = [[122, 31], [132, 31], [132, 39], [122, 39], [122, 31]] + + +class SignalBatchClient: + def __init__( + self, + base_url: str = '', + timeout_sec: int = 60, + ) -> None: + self._base_url = (base_url or settings.SIGNAL_BATCH_URL).rstrip('/') + self._timeout = timeout_sec + + def fetch_recent_detail( + self, + minutes: int = 10, + polygon: Optional[list[list[float]]] = None, + ) -> list[dict]: + """POST /api/v1/vessels/recent-positions-detail + + L0 캐시(AisTargetCacheManager)에서 직접 조회. + 공간 필터(폴리곤) 적용 가능. + + Returns: + list[dict] — RecentPositionDetailResponse 배열 + 각 dict 필드: + mmsi, imo, lon, lat, sog, cog, heading, + shipNm, shipTy(텍스트 선종), shipKindCode(6자리 코드), + nationalCode(MID 숫자), status(항해상태 텍스트), + destination, eta, draught, length, width, lastUpdate + """ + url = f'{self._base_url}/api/v1/vessels/recent-positions-detail' + body: dict = {'minutes': minutes} + if polygon: + body['coordinates'] = polygon + + try: + with httpx.Client(timeout=self._timeout) as client: + resp = client.post(url, json=body) + resp.raise_for_status() + data = resp.json() + logger.info( + 'fetch_recent_detail: %d vessels (minutes=%d)', + len(data), minutes, + ) + return data + except httpx.TimeoutException: + logger.warning('fetch_recent_detail timeout (%ds)', self._timeout) + return [] + except httpx.HTTPStatusError as e: + logger.warning('fetch_recent_detail HTTP %d: %s', e.response.status_code, e) + return [] + except Exception as e: + logger.error('fetch_recent_detail failed: %s', e) + return [] + + def fetch_area_tracks( + self, + start_time: str, + end_time: str, + polygon: Optional[list[list[float]]] = None, + ) -> dict: + """POST /api/v2/tracks/area-search + + D-1~D-7 인메모리 캐시 기반 항적 조회. + 503 시 캐시 미준비 상태. + + Returns: + dict — AreaSearchResponse {tracks, hitDetails, summary} + """ + url = f'{self._base_url}/api/v2/tracks/area-search' + body = { + 'startTime': start_time, + 'endTime': end_time, + 'polygons': [{ + 'id': 'kr-waters', + 'name': '한국 해역', + 'coordinates': polygon or _KR_WATERS_POLYGON, + }], + } + + try: + with httpx.Client(timeout=self._timeout) as client: + resp = client.post(url, json=body) + resp.raise_for_status() + data = resp.json() + tracks = data.get('tracks', []) + logger.info( + 'fetch_area_tracks: %d tracks (%s ~ %s)', + len(tracks), start_time, end_time, + ) + return data + except httpx.HTTPStatusError as e: + if e.response.status_code == 503: + logger.warning('area-search: cache not ready (503)') + else: + logger.warning('area-search HTTP %d', e.response.status_code) + return {} + except Exception as e: + logger.error('fetch_area_tracks failed: %s', e) + return {} + + @staticmethod + def parse_static_info(detail_response: list[dict]) -> dict[str, dict]: + """recent-positions-detail 응답에서 정적정보 dict 추출. + + Returns: + {mmsi: {ship_kind_code, ship_ty, national_code, status, + heading, draught, destination, name, length, width}} + """ + result: dict[str, dict] = {} + for item in detail_response: + mmsi = str(item.get('mmsi', '')) + if not mmsi: + continue + + info: dict = {} + # 선종 (6자리 코드: 000020=어선, 000023=화물, 000028=미분류) + if item.get('shipKindCode'): + info['ship_kind_code'] = item['shipKindCode'] + # 텍스트 선종 ("Cargo", "Tanker", "Vessel", "N/A") + if item.get('shipTy'): + info['ship_ty'] = item['shipTy'] + # 국적 MID ("412"=중국, "440"=한국) + if item.get('nationalCode'): + info['national_code'] = item['nationalCode'] + # 항해 상태 ("Under way using engine", "Anchored", "N/A") + if item.get('status') and item['status'] != 'N/A': + info['status'] = item['status'] + # 선수 방향 + if item.get('heading') is not None: + info['heading'] = float(item['heading']) + # 흘수 + if item.get('draught') is not None and item['draught'] > 0: + info['draught'] = float(item['draught']) + # 목적지 + if item.get('destination'): + info['destination'] = item['destination'] + # 기본 정보 (기존 SNPDB 정적정보와 동일 필드) + if item.get('shipNm'): + info['name'] = item['shipNm'] + if item.get('length') and item['length'] > 0: + info['length'] = int(item['length']) + if item.get('width') and item['width'] > 0: + info['width'] = int(item['width']) + + if info: + result[mmsi] = info + + return result + + +# 모듈 레벨 싱글턴 +signal_client = SignalBatchClient() diff --git a/prediction/main.py b/prediction/main.py index e16283a..d75236f 100644 --- a/prediction/main.py +++ b/prediction/main.py @@ -31,6 +31,10 @@ async def lifespan(application: FastAPI): vessel_store.load_initial(settings.INITIAL_LOAD_HOURS) logger.info('initial load complete: %s', vessel_store.stats()) + # signal-batch API에서 정적정보 초기 보강 (120분 범위, 최대 커버리지) + vessel_store.enrich_from_signal_api(minutes=120) + logger.info('signal-batch enrich complete') + start_scheduler() yield stop_scheduler() diff --git a/prediction/scheduler.py b/prediction/scheduler.py index 5b2e65e..7f6070d 100644 --- a/prediction/scheduler.py +++ b/prediction/scheduler.py @@ -52,11 +52,12 @@ def _fetch_dark_history(kcg_conn, mmsi_list: list[str]) -> dict[str, dict]: cur.execute( """ SELECT mmsi, - count(*) AS n7, - count(*) FILTER (WHERE analyzed_at > now() - interval '24 hours') AS n24, + count(DISTINCT analyzed_at::date) AS n7, + count(DISTINCT analyzed_at::date) FILTER (WHERE analyzed_at > now() - interval '24 hours') AS n24, max(analyzed_at) AS last_at FROM kcg.vessel_analysis_results WHERE is_dark = true + AND gap_duration_min >= 100 AND analyzed_at > now() - interval '7 days' AND mmsi = ANY(%s) GROUP BY mmsi @@ -107,6 +108,8 @@ def run_analysis_cycle(): # 정적정보 / 허가어선 주기적 갱신 vessel_store.refresh_static_info() vessel_store.refresh_permit_registry() + # signal-batch API 정적정보 보강 (shipKindCode, status, heading, draught 등) + vessel_store.enrich_from_signal_api(minutes=10) # 2. 분석 대상 선별 (SOG/COG 계산 포함) df_targets = vessel_store.select_analysis_targets() @@ -247,9 +250,15 @@ def run_analysis_cycle(): dark = bool(gap_info.get('is_dark')) gap_min = int(gap_info.get('gap_min') or 0) history = dark_history_map.get(mmsi, {'count_7d': 0, 'count_24h': 0}) + v_info = vessel_store.get_vessel_info(mmsi) + last_cog_val = float(df_v.iloc[-1].get('cog', 0)) if len(df_v) > 0 else None score, patterns, tier = compute_dark_suspicion( gap_info, mmsi, is_permitted, history, now_kst_hour, classify_zone, + ship_kind_code=v_info.get('ship_kind_code', ''), + nav_status=v_info.get('status', ''), + heading=v_info.get('heading'), + last_cog=last_cog_val, ) pipeline_dark_tiers[tier] = pipeline_dark_tiers.get(tier, 0) + 1 dark_features = { @@ -385,9 +394,15 @@ def run_analysis_cycle(): gap_min = int(gap_info.get('gap_min') or 0) history = dark_history_map.get(mmsi, {'count_7d': 0, 'count_24h': 0}) + lw_info = vessel_store.get_vessel_info(mmsi) + lw_last_cog = float(df_v.iloc[-1].get('cog', 0)) if df_v is not None and len(df_v) > 0 else None score, patterns, tier = compute_dark_suspicion( gap_info, mmsi, is_permitted, history, now_kst_hour, classify_zone, + ship_kind_code=lw_info.get('ship_kind_code', ''), + nav_status=lw_info.get('status', ''), + heading=lw_info.get('heading'), + last_cog=lw_last_cog, ) lw_dark_tiers[tier] = lw_dark_tiers.get(tier, 0) + 1 diff --git a/prediction/scripts/diagnostic-snapshot.sh b/prediction/scripts/diagnostic-snapshot.sh new file mode 100644 index 0000000..8de47ce --- /dev/null +++ b/prediction/scripts/diagnostic-snapshot.sh @@ -0,0 +1,326 @@ +#!/bin/bash +# prediction 알고리즘 진단 스냅샷 수집기 (5분 주기, 수동 종료까지 연속 실행) +# +# 용도: 알고리즘 재설계 후 동작 검증용. 단순 집계가 아닌 개별 판정 과정 추적. +# 실행: nohup bash /home/apps/kcg-ai-prediction/scripts/diagnostic-snapshot.sh & +# 종료: kill $(cat /home/apps/kcg-ai-prediction/data/diag/diag.pid) +# 출력: /home/apps/kcg-ai-prediction/data/diag/YYYYMMDD-HHMM.txt + +set -u + +OUTDIR=/home/apps/kcg-ai-prediction/data/diag +mkdir -p "$OUTDIR" +echo $$ > "$OUTDIR/diag.pid" + +export PGPASSWORD=Kcg2026ai +PSQL="psql -U kcg-app -d kcgaidb -h 211.208.115.83 -P pager=off -x" +PSQL_TABLE="psql -U kcg-app -d kcgaidb -h 211.208.115.83 -P pager=off" + +INTERVAL_SEC=300 # 5분 + +while true; do + +STAMP=$(date '+%Y%m%d-%H%M') +OUT="$OUTDIR/$STAMP.txt" + +{ +echo "###################################################################" +echo "# PREDICTION DIAGNOSTIC SNAPSHOT" +echo "# generated: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "# host: $(hostname)" +echo "# interval: ${INTERVAL_SEC}s" +echo "###################################################################" + +#=================================================================== +# PART 1: 종합 지표 +#=================================================================== +echo "" +echo "=================================================================" +echo "PART 1: 종합 지표 (last 5min)" +echo "=================================================================" + +$PSQL_TABLE << 'SQL' +SELECT count(*) total, + count(*) FILTER (WHERE vessel_type != 'UNKNOWN') pipeline, + count(*) FILTER (WHERE vessel_type = 'UNKNOWN') lightweight, + count(*) FILTER (WHERE is_dark) dark, + count(*) FILTER (WHERE transship_suspect) transship, + count(*) FILTER (WHERE risk_level='CRITICAL') crit, + count(*) FILTER (WHERE risk_level='HIGH') high, + round(avg(risk_score)::numeric, 1) avg_risk, + max(risk_score) max_risk, + round(count(*) FILTER (WHERE is_dark)::numeric / NULLIF(count(*), 0) * 100, 1) AS dark_pct +FROM kcg.vessel_analysis_results +WHERE analyzed_at > now() - interval '5 minutes'; +SQL + +#=================================================================== +# PART 2: 다크베셀 심층 진단 +#=================================================================== +echo "" +echo "=================================================================" +echo "PART 2: DARK VESSEL 심층 진단" +echo "=================================================================" + +echo "" +echo "--- 2-1. dark_suspicion_score 히스토그램 ---" +$PSQL_TABLE << 'SQL' +SELECT CASE + WHEN (features->>'dark_suspicion_score')::int >= 90 THEN 'a_90-100 (CRITICAL_HIGH)' + WHEN (features->>'dark_suspicion_score')::int >= 70 THEN 'b_70-89 (CRITICAL)' + WHEN (features->>'dark_suspicion_score')::int >= 50 THEN 'c_50-69 (HIGH)' + WHEN (features->>'dark_suspicion_score')::int >= 30 THEN 'd_30-49 (WATCH)' + WHEN (features->>'dark_suspicion_score')::int >= 1 THEN 'e_1-29 (NONE_SCORED)' + ELSE 'f_0 (NOT_DARK)' + END bucket, + count(*) cnt, + round(avg(gap_duration_min)::numeric, 0) avg_gap_min, + round(avg(risk_score)::numeric, 1) avg_risk +FROM kcg.vessel_analysis_results +WHERE analyzed_at > now() - interval '5 minutes' + AND is_dark = true +GROUP BY bucket ORDER BY bucket; +SQL + +echo "" +echo "--- 2-2. dark_patterns 발동 빈도 (어떤 규칙이 얼마나 적용되는지) ---" +$PSQL_TABLE << 'SQL' +SELECT pattern, + count(*) cnt, + round(count(*)::numeric / NULLIF((SELECT count(*) FROM kcg.vessel_analysis_results + WHERE analyzed_at > now() - interval '5 minutes' AND is_dark), 0) * 100, 1) AS pct +FROM kcg.vessel_analysis_results, + LATERAL jsonb_array_elements_text(features->'dark_patterns') AS pattern +WHERE analyzed_at > now() - interval '5 minutes' + AND is_dark = true +GROUP BY pattern ORDER BY cnt DESC; +SQL + +echo "" +echo "--- 2-3. P9 선종별 dark 분포 (신규 패턴 검증) ---" +$PSQL_TABLE << 'SQL' +SELECT + CASE WHEN features->>'dark_patterns' LIKE '%fishing_vessel_dark%' THEN 'FISHING(+10)' + WHEN features->>'dark_patterns' LIKE '%cargo_natural_gap%' THEN 'CARGO(-10)' + ELSE 'NO_KIND_EFFECT' END AS p9_effect, + count(*) cnt, + round(avg((features->>'dark_suspicion_score')::int)::numeric, 1) avg_score, + round(avg(gap_duration_min)::numeric, 0) avg_gap +FROM kcg.vessel_analysis_results +WHERE analyzed_at > now() - interval '5 minutes' + AND is_dark = true +GROUP BY p9_effect ORDER BY cnt DESC; +SQL + +echo "" +echo "--- 2-4. P10 항해상태 dark 분포 (신규 패턴 검증) ---" +$PSQL_TABLE << 'SQL' +SELECT + CASE WHEN features->>'dark_patterns' LIKE '%underway_deliberate_off%' THEN 'UNDERWAY_OFF(+20)' + WHEN features->>'dark_patterns' LIKE '%anchored_natural_gap%' THEN 'ANCHORED(-15)' + ELSE 'NO_STATUS_EFFECT' END AS p10_effect, + count(*) cnt, + round(avg((features->>'dark_suspicion_score')::int)::numeric, 1) avg_score +FROM kcg.vessel_analysis_results +WHERE analyzed_at > now() - interval '5 minutes' + AND is_dark = true +GROUP BY p10_effect ORDER BY cnt DESC; +SQL + +echo "" +echo "--- 2-5. P11 heading/COG 불일치 (신규 패턴 검증) ---" +$PSQL_TABLE << 'SQL' +SELECT + CASE WHEN features->>'dark_patterns' LIKE '%heading_cog_mismatch%' THEN 'MISMATCH(+15)' + ELSE 'NO_MISMATCH' END AS p11_effect, + count(*) cnt, + round(avg((features->>'dark_suspicion_score')::int)::numeric, 1) avg_score +FROM kcg.vessel_analysis_results +WHERE analyzed_at > now() - interval '5 minutes' + AND is_dark = true +GROUP BY p11_effect ORDER BY cnt DESC; +SQL + +echo "" +echo "--- 2-6. GAP 구간별 dark_tier 교차표 (임계값 100분 검증) ---" +$PSQL_TABLE << 'SQL' +SELECT CASE + WHEN gap_duration_min < 100 THEN 'a_lt100 (NOT_DARK 예상)' + WHEN gap_duration_min < 180 THEN 'b_100-179' + WHEN gap_duration_min < 360 THEN 'c_180-359' + WHEN gap_duration_min < 720 THEN 'd_360-719' + ELSE 'e_gte720' END gap_bucket, + count(*) total, + count(*) FILTER (WHERE is_dark) dark, + count(*) FILTER (WHERE features->>'dark_tier' = 'CRITICAL') crit, + count(*) FILTER (WHERE features->>'dark_tier' = 'HIGH') high, + count(*) FILTER (WHERE features->>'dark_tier' = 'WATCH') watch, + count(*) FILTER (WHERE features->>'dark_tier' = 'NONE') tier_none +FROM kcg.vessel_analysis_results +WHERE analyzed_at > now() - interval '5 minutes' +GROUP BY gap_bucket ORDER BY gap_bucket; +SQL + +echo "" +echo "--- 2-7. CRITICAL dark 상위 10건 (개별 판정 상세) ---" +$PSQL_TABLE << 'SQL' +SELECT mmsi, gap_duration_min, zone_code, activity_state, + (features->>'dark_suspicion_score')::int AS score, + features->>'dark_tier' AS tier, + features->>'dark_patterns' AS patterns, + risk_score, risk_level +FROM kcg.vessel_analysis_results +WHERE analyzed_at > now() - interval '5 minutes' + AND features->>'dark_tier' = 'CRITICAL' +ORDER BY (features->>'dark_suspicion_score')::int DESC +LIMIT 10; +SQL + +#=================================================================== +# PART 3: 환적 탐지 심층 진단 +#=================================================================== +echo "" +echo "=================================================================" +echo "PART 3: TRANSSHIPMENT 심층 진단" +echo "=================================================================" + +echo "" +echo "--- 3-1. 환적 의심 건수 + 점수 분포 ---" +$PSQL_TABLE << 'SQL' +SELECT count(*) total_suspects, + count(*) FILTER (WHERE (features->>'transship_score')::numeric >= 70) critical, + count(*) FILTER (WHERE (features->>'transship_score')::numeric >= 50 + AND (features->>'transship_score')::numeric < 70) high, + round(avg((features->>'transship_score')::numeric)::numeric, 1) avg_score, + round(avg(transship_duration_min)::numeric, 0) avg_duration_min +FROM kcg.vessel_analysis_results +WHERE analyzed_at > now() - interval '5 minutes' + AND transship_suspect = true; +SQL + +echo "" +echo "--- 3-2. 환적 의심 개별 건 상세 (전체) ---" +$PSQL_TABLE << 'SQL' +SELECT mmsi, transship_pair_mmsi AS pair_mmsi, + transship_duration_min AS dur_min, + (features->>'transship_score')::numeric AS score, + features->>'transship_tier' AS tier, + zone_code, + activity_state, + risk_score +FROM kcg.vessel_analysis_results +WHERE analyzed_at > now() - interval '5 minutes' + AND transship_suspect = true +ORDER BY (features->>'transship_score')::numeric DESC; +SQL + +echo "" +echo "--- 3-3. 환적 후보 선종 분포 (Stage 1 이종 쌍 검증) ---" +echo " (이 쿼리는 journalctl 로그에서 추출)" +journalctl -u kcg-ai-prediction --since '6 minutes ago' --no-pager 2>/dev/null | \ + grep -o 'transshipment:.*' | tail -1 + +#=================================================================== +# PART 4: 이벤트 + KPI +#=================================================================== +echo "" +echo "=================================================================" +echo "PART 4: 이벤트 + KPI (시스템 출력 검증)" +echo "=================================================================" + +echo "" +echo "--- 4-1. prediction_events (last 5min) ---" +$PSQL_TABLE << 'SQL' +SELECT category, level, count(*) cnt +FROM kcg.prediction_events +WHERE created_at > now() - interval '5 minutes' +GROUP BY category, level ORDER BY cnt DESC; +SQL + +echo "" +echo "--- 4-2. KPI 실시간 ---" +$PSQL_TABLE << 'SQL' +SELECT kpi_key, value, trend, delta_pct, updated_at +FROM kcg.prediction_kpi_realtime ORDER BY kpi_key; +SQL + +#=================================================================== +# PART 5: signal-batch 정적정보 보강 검증 +#=================================================================== +echo "" +echo "=================================================================" +echo "PART 5: signal-batch 정적정보 보강 검증" +echo "=================================================================" + +echo "" +echo "--- 5-1. 직전 사이클 enrich 로그 ---" +journalctl -u kcg-ai-prediction --since '6 minutes ago' --no-pager 2>/dev/null | \ + grep -E 'signal-batch enrich|fetch_recent_detail' | tail -2 + +echo "" +echo "--- 5-2. features 내 신규 패턴(P9/P10/P11) 적용 비율 ---" +$PSQL_TABLE << 'SQL' +WITH dark_vessels AS ( + SELECT features FROM kcg.vessel_analysis_results + WHERE analyzed_at > now() - interval '5 minutes' AND is_dark = true +) +SELECT + count(*) AS total_dark, + count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%fishing_vessel_dark%' + OR features->>'dark_patterns' LIKE '%cargo_natural_gap%') AS p9_applied, + count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%underway_deliberate_off%' + OR features->>'dark_patterns' LIKE '%anchored_natural_gap%') AS p10_applied, + count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%heading_cog_mismatch%') AS p11_applied, + round(count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%fishing_vessel_dark%' + OR features->>'dark_patterns' LIKE '%cargo_natural_gap%')::numeric + / NULLIF(count(*), 0) * 100, 1) AS p9_pct, + round(count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%underway_deliberate_off%' + OR features->>'dark_patterns' LIKE '%anchored_natural_gap%')::numeric + / NULLIF(count(*), 0) * 100, 1) AS p10_pct, + round(count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%heading_cog_mismatch%')::numeric + / NULLIF(count(*), 0) * 100, 1) AS p11_pct +FROM dark_vessels; +SQL + +#=================================================================== +# PART 6: 사이클 로그 (직전 6분) +#=================================================================== +echo "" +echo "=================================================================" +echo "PART 6: 사이클 로그 (최근 6분)" +echo "=================================================================" +journalctl -u kcg-ai-prediction --since '6 minutes ago' --no-pager 2>/dev/null | \ + grep -E 'analysis cycle:|lightweight analysis:|pipeline dark:|event_generator:|kpi_writer:|stats_aggregator|enrich|transship|ERROR|Traceback' | \ + tail -20 + +#=================================================================== +# PART 7: 해역별 + 위험도 교차 (운영 지표) +#=================================================================== +echo "" +echo "=================================================================" +echo "PART 7: 해역별 × 위험도 교차표" +echo "=================================================================" +$PSQL_TABLE << 'SQL' +SELECT zone_code, + count(*) total, + count(*) FILTER (WHERE is_dark) dark, + count(*) FILTER (WHERE risk_level='CRITICAL') crit, + count(*) FILTER (WHERE risk_level='HIGH') high, + round(avg(risk_score)::numeric, 1) avg_risk, + count(*) FILTER (WHERE transship_suspect) transship +FROM kcg.vessel_analysis_results +WHERE analyzed_at > now() - interval '5 minutes' +GROUP BY zone_code ORDER BY total DESC; +SQL + +echo "" +echo "=================================================================" +echo "END OF SNAPSHOT $STAMP" +echo "=================================================================" + +} > "$OUT" 2>&1 + +echo "[diag] $(date '+%H:%M:%S') saved: $OUT ($(wc -l < "$OUT") lines)" + +sleep $INTERVAL_SEC +done From df75e085a7c6b15b58347694a896fd8095b1e4b0 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 13 Apr 2026 11:12:19 +0900 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20[Unreleased]=20LGCNS=20MLOps=20+=20AI=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=20=EB=A9=94=EB=89=B4=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/RELEASE-NOTES.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 485073c..d7ebd08 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,12 @@ ## [Unreleased] +### 추가 +- **LGCNS MLOps 메뉴** — 시스템관리 > AI 플랫폼 하위, 모델 레지스트리/학습 파이프라인/서빙 현황/모델 모니터링 탭 구성 +- **AI 보안(SER-10) 메뉴** — 시스템관리 > 감사·보안 하위, AI 모델 보안 감사/Adversarial 공격 탐지/데이터 무결성 검증/보안 이벤트 타임라인 +- **AI Agent 보안(SER-11) 메뉴** — 시스템관리 > 감사·보안 하위, 에이전트 실행 로그/정책 위반 탐지/자원 사용 모니터링/신뢰도 대시보드 +- **V025 마이그레이션** — auth_perm_tree에 admin:lgcns-mlops, admin:ai-security, admin:ai-agent-security 노드 + ADMIN 역할 CRUD 권한 시드 + ## [2026-04-09.2] ### 추가 From 2eddd01d176a23c17b86dfcddff46d4c6de1f9b9 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 13 Apr 2026 11:14:44 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-04-13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/RELEASE-NOTES.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index d7ebd08..93ad9ff 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,11 +4,19 @@ ## [Unreleased] +## [2026-04-13] + ### 추가 - **LGCNS MLOps 메뉴** — 시스템관리 > AI 플랫폼 하위, 모델 레지스트리/학습 파이프라인/서빙 현황/모델 모니터링 탭 구성 - **AI 보안(SER-10) 메뉴** — 시스템관리 > 감사·보안 하위, AI 모델 보안 감사/Adversarial 공격 탐지/데이터 무결성 검증/보안 이벤트 타임라인 - **AI Agent 보안(SER-11) 메뉴** — 시스템관리 > 감사·보안 하위, 에이전트 실행 로그/정책 위반 탐지/자원 사용 모니터링/신뢰도 대시보드 - **V025 마이그레이션** — auth_perm_tree에 admin:lgcns-mlops, admin:ai-security, admin:ai-agent-security 노드 + ADMIN 역할 CRUD 권한 시드 +- **prediction 알고리즘 재설계** — dark_vessel 의심 점수화(8패턴 0~100), transshipment 베테랑 재설계, vessel_store/scheduler 개선 +- **프론트엔드 지도 레이어 구조 정리** — BaseMap, useMapLayers, static layers 리팩토링 + +### 변경 +- **NoticeManagement CRUD 권한 가드** — admin:notices CREATE/UPDATE/DELETE 체크 추가 (disabled + 툴팁) +- **EventList CRUD 권한 가드** — enforcement:event-list UPDATE + enforcement:enforcement-history CREATE 체크 추가 (disabled + 툴팁) ## [2026-04-09.2]