release: 2026-04-13 (21건 커밋) #32
@ -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;
|
||||
@ -4,6 +4,20 @@
|
||||
|
||||
## [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]
|
||||
|
||||
### 추가
|
||||
|
||||
@ -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';
|
||||
|
||||
/*
|
||||
|
||||
@ -80,6 +80,9 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
|
||||
'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<string, LazyComponent> = {
|
||||
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) => ({
|
||||
|
||||
295
frontend/src/features/admin/AIAgentSecurityPage.tsx
Normal file
295
frontend/src/features/admin/AIAgentSecurityPage.tsx
Normal file
@ -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<Tab>('overview');
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Bot}
|
||||
iconColor="text-orange-400"
|
||||
title="AI Agent 구축·운영 보안"
|
||||
description="SER-11 | AI Agent 화이트리스트·자동중단·민감명령 승인·MCP 최소권한·감사로그"
|
||||
demo
|
||||
/>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex gap-0 border-b border-border">
|
||||
{([
|
||||
{ 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 => (
|
||||
<button type="button" key={t.key} onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-orange-400 border-orange-400' : 'text-hint border-transparent hover:text-label'}`}>
|
||||
<t.icon className="w-3.5 h-3.5" />{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── ① Agent 현황 ── */}
|
||||
{tab === 'overview' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
{AGENT_KPI.map(k => (
|
||||
<div key={k.label} className="flex-1 px-4 py-3 rounded-xl border border-border bg-card" style={{ borderLeftColor: k.color, borderLeftWidth: 3 }}>
|
||||
<div className="text-xl font-bold" style={{ color: k.color }}>{k.value}</div>
|
||||
<div className="text-[9px] text-hint">{k.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">등록 Agent 목록</div>
|
||||
<table className="w-full text-[10px]">
|
||||
<thead><tr className="text-hint border-b border-border">
|
||||
<th className="text-left py-2 px-2">Agent명</th><th className="text-center py-2">유형</th>
|
||||
<th className="text-center py-2">도구</th><th className="text-center py-2">24h 호출</th>
|
||||
<th className="text-center py-2">최근 호출</th><th className="text-center py-2">상태</th>
|
||||
</tr></thead>
|
||||
<tbody>{AGENTS.map(a => (
|
||||
<tr key={a.name} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||
<td className="py-2 px-2 text-heading font-medium">{a.name}</td>
|
||||
<td className="py-2 text-center"><Badge intent={a.type === '관리자' ? 'high' : a.type.includes('쓰기') ? 'warning' : 'info'} size="sm">{a.type}</Badge></td>
|
||||
<td className="py-2 text-center text-heading">{a.tools}</td>
|
||||
<td className="py-2 text-center text-muted-foreground">{a.calls24h.toLocaleString()}</td>
|
||||
<td className="py-2 text-center text-muted-foreground">{a.lastCall}</td>
|
||||
<td className="py-2 text-center"><Badge intent={getStatusIntent(a.status)} size="sm">{a.status}</Badge></td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ② 화이트리스트 도구 ── */}
|
||||
{tab === 'whitelist' && (
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">화이트리스트 기반 도구 관리</div>
|
||||
<p className="text-[10px] text-hint mb-3">AI가 사용할 수 있는 도구를 화이트리스트로 지정·관리하여 잘못된 도구를 사용하지 못하도록 구성</p>
|
||||
<table className="w-full text-[10px]">
|
||||
<thead><tr className="text-hint border-b border-border">
|
||||
<th className="text-left py-2 px-2">Tool ID</th><th className="text-left py-2">설명</th>
|
||||
<th className="text-center py-2">Agent</th><th className="text-center py-2">권한</th>
|
||||
<th className="text-left py-2">MCP Server</th><th className="text-center py-2">상태</th>
|
||||
</tr></thead>
|
||||
<tbody>{WHITELIST_TOOLS.map(t => (
|
||||
<tr key={t.tool} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||
<td className="py-2 px-2 text-heading font-mono text-[9px]">{t.tool}</td>
|
||||
<td className="py-2 text-hint">{t.desc}</td>
|
||||
<td className="py-2 text-center text-muted-foreground">{t.agent}</td>
|
||||
<td className="py-2 text-center"><Badge intent={t.permission === 'DELETE' || t.permission === 'ADMIN' ? 'critical' : t.permission === 'READ' ? 'info' : 'warning'} size="sm">{t.permission}</Badge></td>
|
||||
<td className="py-2 text-muted-foreground font-mono text-[9px]">{t.mcp}</td>
|
||||
<td className="py-2 text-center"><Badge intent={t.status === '허용' ? 'success' : 'critical'} size="sm">{t.status}</Badge></td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
|
||||
{/* ── ③ 자동 중단·민감명령 승인 ── */}
|
||||
{tab === 'killswitch' && (
|
||||
<div className="space-y-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">AI 에이전트 자동 중단 (Kill Switch)</div>
|
||||
<div className="space-y-2">
|
||||
{KILL_SWITCH_RULES.map(r => (
|
||||
<div key={r.rule} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400" />
|
||||
<div className="flex-1">
|
||||
<div className="text-[11px] text-heading font-medium">{r.rule}</div>
|
||||
<div className="text-[9px] text-hint">{r.desc}</div>
|
||||
</div>
|
||||
<span className="text-[9px] text-muted-foreground">임계값: {r.threshold}</span>
|
||||
<Badge intent={getStatusIntent(r.status)} size="sm">{r.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">민감명령 승인 절차 (Human-in-the-loop)</div>
|
||||
<p className="text-[10px] text-hint mb-3">국가안보·사회안전에 영향을 미칠 수 있는 민감한 명령은 전 담당자 승인 절차 마련</p>
|
||||
<table className="w-full text-[10px]">
|
||||
<thead><tr className="text-hint border-b border-border">
|
||||
<th className="text-left py-2 px-2">명령 유형</th><th className="text-center py-2">위험도</th>
|
||||
<th className="text-left py-2">승인 방식</th><th className="text-center py-2">HITL</th><th className="text-center py-2">상태</th>
|
||||
</tr></thead>
|
||||
<tbody>{SENSITIVE_COMMANDS.map(c => (
|
||||
<tr key={c.command} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||
<td className="py-2 px-2 text-heading font-medium">{c.command}</td>
|
||||
<td className="py-2 text-center"><Badge intent={c.level === '높음' ? 'critical' : c.level === '중간' ? 'warning' : 'info'} size="sm">{c.level}</Badge></td>
|
||||
<td className="py-2 text-muted-foreground">{c.approval}</td>
|
||||
<td className="py-2 text-center">{c.hitl ? <Hand className="w-3.5 h-3.5 text-orange-400 mx-auto" /> : <span className="text-hint">-</span>}</td>
|
||||
<td className="py-2 text-center"><Badge intent="success" size="sm">{c.status}</Badge></td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ④ Agent 신원·권한 ── */}
|
||||
{tab === 'identity' && (
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">에이전트 권한 위임 차단 및 신원 확인</div>
|
||||
<div className="space-y-2">
|
||||
{IDENTITY_POLICIES.map(p => (
|
||||
<div key={p.policy} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<Key className="w-4 h-4 text-orange-400" />
|
||||
<div className="flex-1">
|
||||
<div className="text-[11px] text-heading font-medium">{p.policy}</div>
|
||||
<div className="text-[9px] text-hint">{p.desc}</div>
|
||||
</div>
|
||||
<Badge intent="success" size="sm">{p.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
|
||||
{/* ── ⑤ MCP Tool 권한 ── */}
|
||||
{tab === 'mcp' && (
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">MCP Tool 최소 권한 원칙</div>
|
||||
<p className="text-[10px] text-hint mb-3">MCP를 통해 연결된 각 도구(Tool)는 최소 권한 원칙에 따라 실행 권한을 부여 (예: 조회 전용 Agent는 DB Update 권한을 가진 MCP Tool 호출 불가)</p>
|
||||
<table className="w-full text-[10px]">
|
||||
<thead><tr className="text-hint border-b border-border">
|
||||
<th className="text-left py-2 px-2">MCP Server</th><th className="text-center py-2">Tool 수</th>
|
||||
<th className="text-center py-2">원칙</th><th className="text-left py-2">상세</th><th className="text-center py-2">상태</th>
|
||||
</tr></thead>
|
||||
<tbody>{MCP_PERMISSIONS.map(m => (
|
||||
<tr key={m.server} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||
<td className="py-2.5 px-2 text-heading font-mono text-[9px]">{m.server}</td>
|
||||
<td className="py-2.5 text-center text-heading">{m.tools}</td>
|
||||
<td className="py-2.5 text-center"><Badge intent="info" size="sm">{m.principle}</Badge></td>
|
||||
<td className="py-2.5 text-hint text-[9px]">{m.detail}</td>
|
||||
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{m.status}</Badge></td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
<div className="mt-4 p-3 bg-surface-overlay rounded-lg">
|
||||
<div className="text-[10px] text-heading font-medium mb-1">Rate Limiting & Caching</div>
|
||||
<div className="text-[9px] text-hint">AI 에이전트의 과도한 호출로 인한 레거시 시스템 마비를 방지하기 위해, MCP 서버 단에서 Rate Limiting(요청 제한) 및 Caching(캐싱) 기능을 필수적으로 구현</div>
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
|
||||
{/* ── ⑥ 감사 로그 ── */}
|
||||
{tab === 'audit' && (
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">감사 로그 (Audit Log)</div>
|
||||
<p className="text-[10px] text-hint mb-3">모든 MCP Tool 호출 내역(User Request → LLM → MCP Client → MCP Server → Legacy)은 식별 가능한 형태로 기록, A2A 통신 과정의 의사결정 추적성 보장</p>
|
||||
<table className="w-full text-[10px]">
|
||||
<thead><tr className="text-hint border-b border-border">
|
||||
<th className="text-left py-2 px-2">시각</th><th className="text-left py-2">호출 체인</th>
|
||||
<th className="text-center py-2">Agent</th><th className="text-left py-2">Tool</th><th className="text-center py-2">결과</th><th className="text-right py-2 px-2">지연</th>
|
||||
</tr></thead>
|
||||
<tbody>{AUDIT_LOG_SAMPLE.map(l => (
|
||||
<tr key={l.time + l.tool} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||
<td className="py-2 px-2 text-muted-foreground font-mono">{l.time}</td>
|
||||
<td className="py-2 text-hint text-[9px]">{l.chain}</td>
|
||||
<td className="py-2 text-center text-heading">{l.agent}</td>
|
||||
<td className="py-2 text-heading font-mono text-[9px]">{l.tool}</td>
|
||||
<td className="py-2 text-center"><Badge intent={l.result === '차단' ? 'critical' : l.result.includes('승인') ? 'warning' : 'success'} size="sm">{l.result}</Badge></td>
|
||||
<td className="py-2 px-2 text-right text-muted-foreground">{l.latency}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
351
frontend/src/features/admin/AISecurityPage.tsx
Normal file
351
frontend/src/features/admin/AISecurityPage.tsx
Normal file
@ -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<Tab>('overview');
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Shield}
|
||||
iconColor="text-red-400"
|
||||
title="AI 보안 관리"
|
||||
description="SER-10 | AI 데이터 수집·학습·입출력·경계 보안 및 취약점 관리"
|
||||
demo
|
||||
/>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex gap-0 border-b border-border">
|
||||
{([
|
||||
{ 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 => (
|
||||
<button type="button" key={t.key} onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-red-400 border-red-400' : 'text-hint border-transparent hover:text-label'}`}>
|
||||
<t.icon className="w-3.5 h-3.5" />{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── ① 보안 현황 ── */}
|
||||
{tab === 'overview' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
{SECURITY_KPI.map(k => (
|
||||
<div key={k.label} className="flex-1 flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-card" style={{ borderLeftColor: k.color, borderLeftWidth: 3 }}>
|
||||
<k.icon className="w-5 h-5" style={{ color: k.color }} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold" style={{ color: k.color }}>{k.score}</span>
|
||||
<span className="text-[9px] text-hint">/ 100</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-hint">{k.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">보안 정책 준수 현황</div>
|
||||
<div className="space-y-1.5 text-[10px]">
|
||||
{[
|
||||
['데이터 출처 인증', '6/6 소스 인증 완료', '완료'],
|
||||
['오염데이터 검사', '4/4 검사 활성화', '완료'],
|
||||
['학습데이터 접근통제', '7/7 정책 적용', '완료'],
|
||||
['입출력 필터링', '4/4 필터 활성', '완료'],
|
||||
['경계 보안 설정', '4/4 항목 적용', '완료'],
|
||||
['취약점 점검', '5/6 안전 (1건 주의)', '주의'],
|
||||
['복구 계획', '3/3 활성', '완료'],
|
||||
].map(([k, v, s]) => (
|
||||
<div key={k} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded">
|
||||
{s === '완료' ? <CheckCircle className="w-3 h-3 text-green-500" /> : <AlertTriangle className="w-3 h-3 text-yellow-500" />}
|
||||
<span className="text-heading flex-1">{k}</span>
|
||||
<span className="text-hint">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">최근 보안 이벤트</div>
|
||||
<div className="space-y-1.5 text-[10px]">
|
||||
{[
|
||||
['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]) => (
|
||||
<div key={time + event} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded">
|
||||
<span className="text-muted-foreground w-24">{time}</span>
|
||||
<span className="text-heading flex-1">{event}</span>
|
||||
<Badge intent={getStatusIntent(level)} size="sm">{level}</Badge>
|
||||
<span className="text-hint text-[9px]">{detail}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ② 데이터 수집 보안 ── */}
|
||||
{tab === 'data' && (
|
||||
<div className="space-y-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">데이터 소스 신뢰성 관리</div>
|
||||
<table className="w-full text-[10px]">
|
||||
<thead><tr className="text-hint border-b border-border">
|
||||
<th className="text-left py-2 px-2">데이터 소스</th><th className="text-left py-2">제공기관</th>
|
||||
<th className="text-center py-2">신뢰등급</th><th className="text-center py-2">암호화</th>
|
||||
<th className="text-center py-2">최근 감사</th><th className="text-center py-2">상태</th>
|
||||
</tr></thead>
|
||||
<tbody>{DATA_SOURCES.map(d => (
|
||||
<tr key={d.name} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||
<td className="py-2 px-2 text-heading font-medium">{d.name}</td>
|
||||
<td className="py-2 text-muted-foreground">{d.provider}</td>
|
||||
<td className="py-2 text-center"><Badge intent="success" size="sm">{d.trust}</Badge></td>
|
||||
<td className="py-2 text-center text-muted-foreground">{d.encryption}</td>
|
||||
<td className="py-2 text-center text-muted-foreground">{d.lastAudit}</td>
|
||||
<td className="py-2 text-center"><Badge intent={getStatusIntent(d.status)} size="sm">{d.status}</Badge></td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">오염데이터 방지 검사</div>
|
||||
<div className="space-y-2">
|
||||
{CONTAMINATION_CHECKS.map(c => (
|
||||
<div key={c.check} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<div className="flex-1">
|
||||
<div className="text-[11px] text-heading font-medium">{c.check}</div>
|
||||
<div className="text-[9px] text-hint">{c.desc}</div>
|
||||
</div>
|
||||
<Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge>
|
||||
<span className="text-[9px] text-muted-foreground">최근: {c.lastRun}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ③ AI 학습 보안 ── */}
|
||||
{tab === 'training' && (
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">학습 데이터 보안 정책</div>
|
||||
<div className="space-y-2">
|
||||
{TRAINING_POLICIES.map(p => (
|
||||
<div key={p.policy} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<Lock className="w-4 h-4 text-blue-400" />
|
||||
<div className="flex-1">
|
||||
<div className="text-[11px] text-heading font-medium">{p.policy}</div>
|
||||
<div className="text-[9px] text-hint">{p.desc}</div>
|
||||
</div>
|
||||
<Badge intent="success" size="sm">{p.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
|
||||
{/* ── ④ 입출력 보안 ── */}
|
||||
{tab === 'io' && (
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">AI 시스템 입·출력 보안 대책</div>
|
||||
<table className="w-full text-[10px]">
|
||||
<thead><tr className="text-hint border-b border-border">
|
||||
<th className="text-left py-2 px-2">필터</th><th className="text-left py-2">설명</th>
|
||||
<th className="text-center py-2">상태</th><th className="text-center py-2">차단</th><th className="text-center py-2">총 요청</th>
|
||||
<th className="text-center py-2">차단율</th>
|
||||
</tr></thead>
|
||||
<tbody>{IO_FILTERS.map(f => (
|
||||
<tr key={f.name} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||
<td className="py-2.5 px-2 text-heading font-medium">{f.name}</td>
|
||||
<td className="py-2.5 text-hint text-[9px]">{f.desc}</td>
|
||||
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{f.status}</Badge></td>
|
||||
<td className="py-2.5 text-center text-red-400 font-bold">{f.blocked}</td>
|
||||
<td className="py-2.5 text-center text-muted-foreground">{f.total.toLocaleString()}</td>
|
||||
<td className="py-2.5 text-center text-muted-foreground">{(f.blocked / f.total * 100).toFixed(2)}%</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
|
||||
{/* ── ⑤ 경계 보안 ── */}
|
||||
{tab === 'boundary' && (
|
||||
<div className="space-y-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">AI 시스템 경계 보안</div>
|
||||
<div className="space-y-2">
|
||||
{BOUNDARY_ITEMS.map(b => (
|
||||
<div key={b.item} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<Server className="w-4 h-4 text-yellow-400" />
|
||||
<div className="flex-1">
|
||||
<div className="text-[11px] text-heading font-medium">{b.item}</div>
|
||||
<div className="text-[9px] text-hint">{b.desc}</div>
|
||||
</div>
|
||||
<Badge intent="success" size="sm">{b.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">설명 가능한 AI (XAI)</div>
|
||||
<div className="space-y-2">
|
||||
{EXPLAINABILITY.map(e => (
|
||||
<div key={e.item} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<Eye className="w-4 h-4 text-purple-400" />
|
||||
<div className="flex-1">
|
||||
<div className="text-[11px] text-heading font-medium">{e.item}</div>
|
||||
<div className="text-[9px] text-hint">{e.desc}</div>
|
||||
</div>
|
||||
<Badge intent="purple" size="sm">{e.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ⑥ 취약점 점검 ── */}
|
||||
{tab === 'vulnerability' && (
|
||||
<div className="space-y-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">AI 시스템 소프트웨어·라이브러리 취약점 점검</div>
|
||||
<table className="w-full text-[10px]">
|
||||
<thead><tr className="text-hint border-b border-border">
|
||||
<th className="text-left py-2 px-2">대상</th><th className="text-center py-2">버전</th>
|
||||
<th className="text-center py-2">최근 스캔</th><th className="text-center py-2">취약점</th><th className="text-center py-2">상태</th>
|
||||
</tr></thead>
|
||||
<tbody>{VULN_CHECKS.map(v => (
|
||||
<tr key={v.target} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||
<td className="py-2 px-2 text-heading font-medium">{v.target}</td>
|
||||
<td className="py-2 text-center text-muted-foreground font-mono">{v.version}</td>
|
||||
<td className="py-2 text-center text-muted-foreground">{v.lastScan}</td>
|
||||
<td className="py-2 text-center">{v.vulns > 0 ? <span className="text-yellow-400 font-bold">{v.vulns}건</span> : <span className="text-green-400">0건</span>}</td>
|
||||
<td className="py-2 text-center"><Badge intent={getStatusIntent(v.status)} size="sm">{v.status}</Badge></td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">AI 시스템 복구 방안</div>
|
||||
<div className="space-y-2">
|
||||
{RECOVERY_PLANS.map(r => (
|
||||
<div key={r.plan} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<RefreshCw className="w-4 h-4 text-cyan-400" />
|
||||
<div className="flex-1">
|
||||
<div className="text-[11px] text-heading font-medium">{r.plan}</div>
|
||||
<div className="text-[9px] text-hint">{r.desc}</div>
|
||||
</div>
|
||||
<span className="text-[9px] text-muted-foreground">{r.detail}</span>
|
||||
<Badge intent={getStatusIntent(r.status)} size="sm">{r.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@ -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<SystemNotice[]>(INITIAL_NOTICES);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
@ -146,7 +151,7 @@ export function NoticeManagement() {
|
||||
description={t('notices.desc')}
|
||||
demo
|
||||
actions={
|
||||
<Button variant="primary" size="md" onClick={openNew} icon={<Plus className="w-3.5 h-3.5" />}>
|
||||
<Button variant="primary" size="md" onClick={openNew} disabled={!canCreate} title={!canCreate ? '등록 권한이 필요합니다' : undefined} icon={<Plus className="w-3.5 h-3.5" />}>
|
||||
새 알림 등록
|
||||
</Button>
|
||||
}
|
||||
@ -237,10 +242,10 @@ export function NoticeManagement() {
|
||||
</td>
|
||||
<td className="px-1 py-1.5">
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
<button type="button" onClick={() => openEdit(n)} className="p-1 text-hint hover:text-blue-400" title="수정">
|
||||
<button type="button" onClick={() => openEdit(n)} disabled={!canUpdate} className="p-1 text-hint hover:text-blue-400 disabled:opacity-30 disabled:cursor-not-allowed" title={canUpdate ? '수정' : '수정 권한이 필요합니다'}>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</button>
|
||||
<button type="button" onClick={() => handleDelete(n.id)} className="p-1 text-hint hover:text-red-400" title="삭제">
|
||||
<button type="button" onClick={() => handleDelete(n.id)} disabled={!canDelete} className="p-1 text-hint hover:text-red-400 disabled:opacity-30 disabled:cursor-not-allowed" title={canDelete ? '삭제' : '삭제 권한이 필요합니다'}>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
@ -414,7 +419,7 @@ export function NoticeManagement() {
|
||||
<SaveButton
|
||||
onClick={handleSave}
|
||||
label={editingId ? '수정' : '등록'}
|
||||
disabled={!form.title.trim() || !form.message.trim()}
|
||||
disabled={!form.title.trim() || !form.message.trim() || (editingId ? !canUpdate : !canCreate)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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';
|
||||
|
||||
431
frontend/src/features/ai-operations/LGCNSMLOpsPage.tsx
Normal file
431
frontend/src/features/ai-operations/LGCNSMLOpsPage.tsx
Normal file
@ -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<Tab>('project');
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Box}
|
||||
iconColor="text-cyan-400"
|
||||
title={t('lgcnsMlops.title')}
|
||||
description={t('lgcnsMlops.desc')}
|
||||
demo
|
||||
/>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex gap-0 border-b border-border">
|
||||
{([
|
||||
{ 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 => (
|
||||
<button type="button" key={t.key} onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-cyan-400 border-cyan-400' : 'text-hint border-transparent hover:text-label'}`}>
|
||||
<t.icon className="w-3.5 h-3.5" />{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── ① 프로젝트 관리 ── */}
|
||||
{tab === 'project' && (
|
||||
<div className="space-y-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">프로젝트 목록</div>
|
||||
<table className="w-full text-[10px]">
|
||||
<thead><tr className="text-hint border-b border-border">
|
||||
<th className="text-left py-2 px-2">ID</th><th className="text-left py-2">프로젝트명</th><th className="text-left py-2">담당</th>
|
||||
<th className="text-center py-2">상태</th><th className="text-center py-2">모델</th><th className="text-center py-2">실험</th><th className="text-right py-2 px-2">수정일</th>
|
||||
</tr></thead>
|
||||
<tbody>{PROJECTS.map(p => (
|
||||
<tr key={p.id} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||
<td className="py-2 px-2 text-muted-foreground">{p.id}</td>
|
||||
<td className="py-2 text-heading font-medium">{p.name}</td>
|
||||
<td className="py-2 text-muted-foreground">{p.owner}</td>
|
||||
<td className="py-2 text-center"><Badge intent={getStatusIntent(p.status)} size="sm">{p.status}</Badge></td>
|
||||
<td className="py-2 text-center text-heading">{p.models}</td>
|
||||
<td className="py-2 text-center text-heading">{p.experiments}</td>
|
||||
<td className="py-2 px-2 text-right text-muted-foreground">{p.updated}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</CardContent></Card>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[
|
||||
{ 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 => (
|
||||
<div key={k.label} className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-card" style={{ borderLeftColor: k.color, borderLeftWidth: 3 }}>
|
||||
<k.icon className="w-5 h-5" style={{ color: k.color }} />
|
||||
<div><div className="text-xl font-bold" style={{ color: k.color }}>{k.value}</div><div className="text-[9px] text-hint">{k.label}</div></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ② 분석환경 관리 ── */}
|
||||
{tab === 'environment' && (
|
||||
<div className="space-y-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">분석환경 목록</div>
|
||||
<div className="space-y-2">
|
||||
{ENVIRONMENTS.map(e => (
|
||||
<div key={e.name} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<e.icon className="w-4 h-4 text-cyan-400" />
|
||||
<span className="text-[11px] text-heading font-medium w-36">{e.name}</span>
|
||||
<Badge intent="muted" size="sm">{e.type}</Badge>
|
||||
<span className="text-[10px] text-muted-foreground w-24">GPU: {e.gpu}</span>
|
||||
<span className="text-[10px] text-muted-foreground flex-1">사용자: {e.user}</span>
|
||||
<Badge intent={getStatusIntent(e.status)} size="sm">{e.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">워크플로우</div>
|
||||
<div className="space-y-2">
|
||||
{WORKFLOWS.map(w => (
|
||||
<div key={w.id} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<span className="text-[10px] text-muted-foreground w-16">{w.id}</span>
|
||||
<span className="text-[11px] text-heading font-medium flex-1">{w.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground">Steps: {w.steps}</span>
|
||||
<div className="w-20 h-1.5 bg-switch-background rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${w.status === 'running' ? 'bg-blue-500' : w.status === 'done' ? 'bg-green-500' : 'bg-red-500'}`} style={{ width: `${w.progress}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">{w.progress}%</span>
|
||||
<Badge intent={getStatusIntent(w.status === 'running' ? '실행중' : w.status === 'done' ? '완료' : '실패')} size="sm">
|
||||
{w.status === 'running' ? '실행중' : w.status === 'done' ? '완료' : '실패'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ③ 모델 관리 ── */}
|
||||
{tab === 'model' && (
|
||||
<div className="space-y-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">등록 모델</div>
|
||||
<table className="w-full text-[10px]">
|
||||
<thead><tr className="text-hint border-b border-border">
|
||||
<th className="text-left py-2 px-2">모델명</th><th className="text-left py-2">프레임워크</th><th className="text-center py-2">상태</th>
|
||||
<th className="text-center py-2">정확도</th><th className="text-center py-2">KPI</th><th className="text-left py-2">버전</th><th className="text-left py-2 px-2">Endpoint</th>
|
||||
</tr></thead>
|
||||
<tbody>{MODELS.map(m => (
|
||||
<tr key={m.name} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||
<td className="py-2 px-2 text-heading font-medium">{m.name}</td>
|
||||
<td className="py-2 text-muted-foreground">{m.framework}</td>
|
||||
<td className="py-2 text-center"><Badge intent={getStatusIntent(m.status === 'DEPLOYED' ? '활성' : m.status === 'APPROVED' ? '승인' : m.status === 'TESTING' ? '테스트' : '대기')} size="sm">{m.status}</Badge></td>
|
||||
<td className="py-2 text-center text-heading font-bold">{m.accuracy}%</td>
|
||||
<td className="py-2 text-center text-green-400">{m.kpi}</td>
|
||||
<td className="py-2 text-muted-foreground">{m.version}</td>
|
||||
<td className="py-2 px-2 text-muted-foreground font-mono text-[9px]">{m.endpoint}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">Parameter 관리</div>
|
||||
<table className="w-full text-[10px]">
|
||||
<thead><tr className="text-hint border-b border-border">
|
||||
<th className="text-left py-2 px-2">파라미터</th><th className="text-left py-2">타입</th><th className="text-center py-2">기본값</th><th className="text-left py-2 px-2">범위</th>
|
||||
</tr></thead>
|
||||
<tbody>{PARAMETERS.map(p => (
|
||||
<tr key={p.name} className="border-b border-border/50">
|
||||
<td className="py-1.5 px-2 text-heading font-mono">{p.name}</td>
|
||||
<td className="py-1.5 text-muted-foreground">{p.type}</td>
|
||||
<td className="py-1.5 text-center text-heading">{p.default}</td>
|
||||
<td className="py-1.5 px-2 text-muted-foreground">{p.range}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ④ Job 실행 관리 ── */}
|
||||
{tab === 'job' && (
|
||||
<div className="space-y-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">Job Pipeline</div>
|
||||
<div className="flex items-center gap-1 mb-4 px-2">
|
||||
{PIPELINE_STAGES.map((s, i) => (
|
||||
<div key={s.name} className="flex items-center gap-1 flex-1">
|
||||
<div className={`flex-1 py-2 px-3 rounded-lg text-center text-[10px] font-medium ${
|
||||
s.status === 'done' ? 'bg-green-500/15 text-green-400 border border-green-500/30' :
|
||||
s.status === 'running' ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30 animate-pulse' :
|
||||
'bg-surface-overlay text-hint border border-border'
|
||||
}`}>
|
||||
{s.status === 'done' && <CheckCircle className="w-3 h-3 inline mr-1" />}
|
||||
{s.status === 'running' && <RefreshCw className="w-3 h-3 inline mr-1 animate-spin" />}
|
||||
{s.name}
|
||||
</div>
|
||||
{i < PIPELINE_STAGES.length - 1 && <span className="text-hint text-[10px]">→</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">Job 목록</div>
|
||||
<table className="w-full text-[10px]">
|
||||
<thead><tr className="text-hint border-b border-border">
|
||||
<th className="text-left py-2 px-2">ID</th><th className="text-left py-2">Job명</th><th className="text-center py-2">유형</th>
|
||||
<th className="text-left py-2">리소스</th><th className="text-center py-2">진행률</th><th className="text-center py-2">상태</th><th className="text-right py-2 px-2">소요시간</th>
|
||||
</tr></thead>
|
||||
<tbody>{JOBS.map(j => (
|
||||
<tr key={j.id} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||
<td className="py-2 px-2 text-muted-foreground">{j.id}</td>
|
||||
<td className="py-2 text-heading font-medium">{j.name}</td>
|
||||
<td className="py-2 text-center"><Badge intent="muted" size="sm">{j.type}</Badge></td>
|
||||
<td className="py-2 text-muted-foreground text-[9px]">{j.resource}</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<div className="w-16 h-1.5 bg-switch-background rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${j.status === 'running' ? 'bg-blue-500' : j.status === 'done' ? 'bg-green-500' : 'bg-red-500'}`} style={{ width: `${j.progress}%` }} />
|
||||
</div>
|
||||
<span className="text-heading">{j.progress}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 text-center">
|
||||
<Badge intent={getStatusIntent(j.status === 'running' ? '실행중' : j.status === 'done' ? '완료' : '실패')} size="sm">
|
||||
{j.status === 'running' ? '실행중' : j.status === 'done' ? '완료' : '실패'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2 px-2 text-right text-muted-foreground">{j.elapsed}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ⑤ 공통서비스 ── */}
|
||||
{tab === 'common' && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{COMMON_SERVICES.map(s => (
|
||||
<Card key={s.name}><CardContent className="p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 rounded-lg bg-cyan-500/10">
|
||||
<s.icon className="w-5 h-5 text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-bold text-heading">{s.name}</div>
|
||||
<div className="text-[9px] text-hint">{s.desc}</div>
|
||||
</div>
|
||||
<Badge intent={getStatusIntent(s.status)} size="sm" className="ml-auto">{s.status}</Badge>
|
||||
</div>
|
||||
<div className="space-y-1 text-[10px]">
|
||||
<div className="flex justify-between px-2 py-1 bg-surface-overlay rounded">
|
||||
<span className="text-hint">버전</span><span className="text-label">{s.version}</span>
|
||||
</div>
|
||||
<div className="flex justify-between px-2 py-1 bg-surface-overlay rounded">
|
||||
<span className="text-hint">상세</span><span className="text-label">{s.detail}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ⑥ 모니터링 ── */}
|
||||
{tab === 'monitoring' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
{SYSTEM_METRICS.map(k => (
|
||||
<div key={k.label} className="flex-1 flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-card" style={{ borderLeftColor: k.color, borderLeftWidth: 3 }}>
|
||||
<div><div className="text-xl font-bold" style={{ color: k.color }}>{k.value}</div><div className="text-[9px] text-hint">{k.label}</div></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">GPU 리소스 현황</div>
|
||||
<div className="space-y-2">
|
||||
{GPU_RESOURCES.map(g => (
|
||||
<div key={g.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||
<span className="text-[10px] text-heading font-medium w-24">{g.name}</span>
|
||||
<div className="flex-1 h-2 bg-switch-background rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${g.usage > 80 ? 'bg-red-500' : g.usage > 60 ? 'bg-yellow-500' : 'bg-green-500'}`} style={{ width: `${g.usage}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-heading font-bold w-8">{g.usage}%</span>
|
||||
<span className="text-[9px] text-hint">{g.mem}</span>
|
||||
<span className="text-[9px] text-hint">{g.temp}</span>
|
||||
<span className="text-[9px] text-muted-foreground">{g.job}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">서비스 상태</div>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ 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 => (
|
||||
<div key={s.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||
<div className={`w-2 h-2 rounded-full ${s.status === 'ok' ? 'bg-green-500 shadow-[0_0_4px_#22c55e]' : 'bg-yellow-500 shadow-[0_0_4px_#eab308]'}`} />
|
||||
<span className="text-[10px] text-heading font-medium flex-1">{s.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{s.rps} req/s</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ⑦ Repository ── */}
|
||||
{tab === 'repository' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">소스코드/글로벌 Repository</div>
|
||||
<div className="space-y-1.5 text-[10px]">
|
||||
{[
|
||||
['kcg-ai-monitoring', 'frontend + backend + prediction 모노레포'],
|
||||
['kcg-ml-models', '모델 아카이브 (버전별 weight)'],
|
||||
['kcg-data-pipeline', 'ETL 스크립트 + Airflow DAG'],
|
||||
['kcg-feature-store', '피처 정의 + 변환 로직'],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between px-2 py-1.5 bg-surface-overlay rounded">
|
||||
<span className="text-heading font-mono">{k}</span><span className="text-hint">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">데이터/분석 Repository</div>
|
||||
<div className="space-y-1.5 text-[10px]">
|
||||
{[
|
||||
['AIS 원본 데이터', 'SNPDB · 5분 주기 증분 · 1.2TB'],
|
||||
['학습 데이터셋', '1,456,200건 · 04-03 갱신'],
|
||||
['벡터 DB (Milvus)', '1.2M 문서 · 3.6M 벡터'],
|
||||
['모델 Artifact', 'S3 · 24개 버전 · 12.8GB'],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between px-2 py-1.5 bg-surface-overlay rounded">
|
||||
<span className="text-heading">{k}</span><span className="text-hint">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
export { AIModelManagement } from './AIModelManagement';
|
||||
export { MLOpsPage } from './MLOpsPage';
|
||||
export { LGCNSMLOpsPage } from './LGCNSMLOpsPage';
|
||||
export { AIAssistant } from './AIAssistant';
|
||||
export { LLMOpsPage } from './LLMOpsPage';
|
||||
|
||||
@ -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<MapHandle>(null);
|
||||
|
||||
const buildLayers = useCallback(() => [
|
||||
...STATIC_LAYERS,
|
||||
...createStaticLayers(),
|
||||
createHeatmapLayer('threat-heat', THREAT_HEAT as HeatPoint[], { radiusPixels: 22 }),
|
||||
createMarkerLayer('threat-markers', THREAT_MARKERS),
|
||||
], []);
|
||||
|
||||
@ -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<MapHandle>(null);
|
||||
|
||||
const buildLayers = useCallback(() => [
|
||||
...STATIC_LAYERS,
|
||||
...createStaticLayers(),
|
||||
createRadiusLayer(
|
||||
'dv-radius',
|
||||
DATA.filter((d) => d.darkScore >= 70).map((d) => ({
|
||||
|
||||
@ -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<MapHandle>(null);
|
||||
|
||||
const buildLayers = useCallback(() => [
|
||||
...STATIC_LAYERS,
|
||||
...createStaticLayers(),
|
||||
createRadiusLayer(
|
||||
'gear-radius',
|
||||
DATA.filter(g => g.risk === '고위험').map(g => ({
|
||||
|
||||
@ -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 (
|
||||
<div className="flex items-center gap-1">
|
||||
{isNew && (
|
||||
<button type="button" aria-label="확인" title="확인(ACK)"
|
||||
className="p-0.5 rounded hover:bg-blue-500/20 text-blue-400 disabled:opacity-30"
|
||||
disabled={busy} onClick={(e) => { e.stopPropagation(); handleAck(eid); }}>
|
||||
<button type="button" aria-label="확인" title={canAck ? '확인(ACK)' : '확인 권한이 필요합니다'}
|
||||
className="p-0.5 rounded hover:bg-blue-500/20 text-blue-400 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleAck(eid); }}>
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
@ -180,14 +184,14 @@ export function EventList() {
|
||||
</button>
|
||||
{isActionable && (
|
||||
<>
|
||||
<button type="button" aria-label="단속 등록" title="단속 등록"
|
||||
className="p-0.5 rounded hover:bg-green-500/20 text-green-400 disabled:opacity-30"
|
||||
disabled={busy} onClick={(e) => { e.stopPropagation(); handleCreateEnforcement(row); }}>
|
||||
<button type="button" aria-label="단속 등록" title={canCreateEnforcement ? '단속 등록' : '단속 등록 권한이 필요합니다'}
|
||||
className="p-0.5 rounded hover:bg-green-500/20 text-green-400 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
disabled={busy || !canCreateEnforcement} onClick={(e) => { e.stopPropagation(); handleCreateEnforcement(row); }}>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button type="button" aria-label="오탐 처리" title="오탐 처리"
|
||||
className="p-0.5 rounded hover:bg-red-500/20 text-red-400 disabled:opacity-30"
|
||||
disabled={busy} onClick={(e) => { e.stopPropagation(); handleFalsePositive(eid); }}>
|
||||
<button type="button" aria-label="오탐 처리" title={canAck ? '오탐 처리' : '상태 변경 권한이 필요합니다'}
|
||||
className="p-0.5 rounded hover:bg-red-500/20 text-red-400 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleFalsePositive(eid); }}>
|
||||
<Ban className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
@ -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<string>('');
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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<MapHandle>(null);
|
||||
|
||||
const buildLayers = useCallback(() => [
|
||||
...STATIC_LAYERS,
|
||||
...createStaticLayers(),
|
||||
createRadiusLayer(
|
||||
'ep-radius-confirmed',
|
||||
PLANS.filter(p => p.status === '확정' || p.status === 'CONFIRMED').map(p => ({
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 })),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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·평가·보안·모니터링 통합 운영"
|
||||
|
||||
@ -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": "활성",
|
||||
|
||||
@ -98,6 +98,12 @@ export const BaseMap = memo(forwardRef<MapHandle, BaseMapProps>(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;
|
||||
|
||||
@ -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<T>(
|
||||
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
|
||||
|
||||
@ -14,6 +14,6 @@ export {
|
||||
createPolylineLayer,
|
||||
createHeatmapLayer,
|
||||
createZoneLayer,
|
||||
EEZ_LAYER, NLL_LAYER, STATIC_LAYERS,
|
||||
createStaticLayers,
|
||||
} from './layers';
|
||||
export { useMapLayers, useStoreLayerSync } from './hooks/useMapLayers';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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()];
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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]
|
||||
|
||||
46
prediction/cache/vessel_store.py
vendored
46
prediction/cache/vessel_store.py
vendored
@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@ -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
|
||||
|
||||
45
prediction/data/monitoring_zones.json
Normal file
45
prediction/data/monitoring_zones.json
Normal file
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
174
prediction/db/signal_api.py
Normal file
174
prediction/db/signal_api.py
Normal file
@ -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()
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
326
prediction/scripts/diagnostic-snapshot.sh
Normal file
326
prediction/scripts/diagnostic-snapshot.sh
Normal file
@ -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
|
||||
불러오는 중...
Reference in New Issue
Block a user