Compare commits
12 커밋
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| 594741906b | |||
| 5be83d2d9a | |||
| fafed8ccdf | |||
| 62d14fc519 | |||
| c8673246f3 | |||
| 2f94c2a0a4 | |||
| d0c8a88f21 | |||
| 7d101604cc | |||
| 020b3b7643 | |||
| 21b5048a9c | |||
| 74bdfa3f04 | |||
| 6c7c0f4ca6 |
248
AGENTS.md
248
AGENTS.md
@ -1,248 +0,0 @@
|
||||
# KCG AI Monitoring (모노레포)
|
||||
|
||||
해양경찰청 AI 기반 불법어선 탐지 및 단속 지원 플랫폼
|
||||
|
||||
## 모노레포 구조
|
||||
|
||||
```
|
||||
kcg-ai-monitoring/
|
||||
├── frontend/ # React 19 + TypeScript + Vite (UI)
|
||||
├── backend/ # Spring Boot 3.x + Java 21 (인증/권한/감사 + 분석 API)
|
||||
├── prediction/ # Python 3.11+ + FastAPI (AIS 분석 엔진, 5분 주기)
|
||||
├── database/ # PostgreSQL 마이그레이션 참조용 README (실제 Flyway 파일은 backend/src/main/resources/db/migration/ V001~V030, 51 테이블)
|
||||
│ └── migration/
|
||||
├── deploy/ # 배포 가이드 + 서버 설정 문서
|
||||
├── docs/ # 프로젝트 문서 (SFR, 아키텍처)
|
||||
├── .gitea/ # Gitea Actions CI/CD (프론트 자동배포)
|
||||
├── .Codex/ # Codex 워크플로우
|
||||
├── .githooks/ # Git hooks
|
||||
└── Makefile # 통합 dev/build 명령
|
||||
```
|
||||
|
||||
## 시스템 구성
|
||||
|
||||
```
|
||||
[Frontend Vite :5173] ──→ [Backend Spring :8080] ──→ [PostgreSQL kcgaidb]
|
||||
↑ write
|
||||
[Prediction FastAPI :18092] ─────┘ (5분 주기 분석 결과 저장)
|
||||
↑ read
|
||||
[SNPDB PostgreSQL] (AIS 원본)
|
||||
```
|
||||
|
||||
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습) + prediction 분석 결과 조회 API (`/api/analysis/*`)
|
||||
- **Prediction**: AIS → 분석 결과를 kcgaidb 에 직접 write (백엔드 호출 없음)
|
||||
- **DB 공유 아키텍처**: 백엔드와 prediction 은 HTTP 호출 없이 kcgaidb 를 통해서만 연동
|
||||
|
||||
## 명령어
|
||||
|
||||
```bash
|
||||
make install # 전체 의존성 설치
|
||||
make dev # 프론트 + 백엔드 동시 실행
|
||||
make dev-all # 프론트 + 백엔드 + prediction 동시 실행
|
||||
make dev-frontend # 프론트만
|
||||
make dev-backend # 백엔드만
|
||||
make dev-prediction # prediction 분석 엔진만 (FastAPI :8001)
|
||||
make build # 전체 빌드
|
||||
make lint # 프론트 lint
|
||||
make format # 프론트 prettier
|
||||
```
|
||||
|
||||
## 기술 스택
|
||||
|
||||
### Frontend (`frontend/`)
|
||||
- React 19, TypeScript 5.9, Vite 8
|
||||
- Tailwind CSS 4 + CVA
|
||||
- MapLibre GL 5 + deck.gl 9 (지도)
|
||||
- ECharts 6 (차트)
|
||||
- Zustand 5 (상태관리)
|
||||
- i18next (ko/en)
|
||||
- React Router 7
|
||||
- ESLint 10 + Prettier
|
||||
|
||||
### Prediction (`prediction/`) — 분석 엔진
|
||||
- Python 3.11+, FastAPI, APScheduler
|
||||
- 17개 알고리즘 모듈 (다크베셀, 스푸핑, 환적, 어구 상관·부모·정체성 충돌, 쌍끌이, 위험도, 어선 분류 등)
|
||||
- 7단계 분류 파이프라인 (전처리→행동→리샘플→특징→분류→클러스터→계절)
|
||||
- AIS 원본: SNPDB (5분 증분), 결과: kcgaidb (직접 write)
|
||||
- prediction과 backend는 DB만 공유 (HTTP 호출 X, 단 실시간 상태 조회용 FastAPI 프록시 `/api/prediction/*` 예외)
|
||||
|
||||
### Backend (`backend/`)
|
||||
- Spring Boot 3.x + Java 21
|
||||
- Spring Security + JWT
|
||||
- PostgreSQL + Flyway
|
||||
- Caffeine (권한 캐싱)
|
||||
- 트리 기반 RBAC (wing 패턴)
|
||||
|
||||
### Database (`kcgaidb`)
|
||||
- PostgreSQL
|
||||
- 사용자: `kcg-app`
|
||||
- 스키마: `kcg`
|
||||
|
||||
## 배포 환경
|
||||
|
||||
| 서비스 | 서버 (SSH) | 포트 | 관리 |
|
||||
|---|---|---|---|
|
||||
| 프론트엔드 | rocky-211 | nginx 443 | Gitea Actions 자동배포 |
|
||||
| 백엔드 | rocky-211 | 18080 | `systemctl restart kcg-ai-backend` |
|
||||
| prediction | redis-211 | 18092 | `systemctl restart kcg-ai-prediction` |
|
||||
|
||||
- **URL**: https://kcg-ai-monitoring.gc-si.dev
|
||||
- **배포 상세**: `deploy/README.md` 참조
|
||||
- **CI/CD**: `.gitea/workflows/deploy.yml` (프론트만 자동, 백엔드/prediction 수동)
|
||||
|
||||
## 권한 체계
|
||||
|
||||
좌측 탭(메뉴) = 권한 그룹, 내부 패널/액션 = 자식 자원, CRUD 단위 개별 제어.
|
||||
상세는 `.Codex/plans/vast-tinkering-knuth.md` 참조.
|
||||
|
||||
## 팀 컨벤션
|
||||
|
||||
- 팀 규칙: `.Codex/rules/`
|
||||
- 커밋: Conventional Commits (한국어), `.githooks/commit-msg` 검증
|
||||
- pre-commit: `frontend/` 디렉토리 기준 TypeScript + ESLint 검증
|
||||
|
||||
## 디자인 시스템 (필수 준수)
|
||||
|
||||
프론트엔드 UI는 **`/design-system.html` 쇼케이스를 단일 진실 공급원(SSOT)** 으로 한다.
|
||||
모든 페이지/컴포넌트는 쇼케이스에 정의된 컴포넌트와 토큰만 사용한다.
|
||||
|
||||
### 쇼케이스 진입
|
||||
- **URL**: https://kcg-ai-monitoring.gc-si.dev/design-system.html (메인 SPA와 별개)
|
||||
- **소스**: `frontend/design-system.html` + `frontend/src/designSystemMain.tsx` + `frontend/src/design-system/`
|
||||
- **추적 ID 체계**: `TRK-<카테고리>-<슬러그>` (예: `TRK-BADGE-critical-sm`)
|
||||
- 호버 시 툴팁, "ID 복사 모드"에서 클릭 시 클립보드 복사
|
||||
- URL 해시 딥링크: `#trk=TRK-BUTTON-primary-md`
|
||||
- **단축키 `A`**: 다크/라이트 테마 토글
|
||||
|
||||
### 공통 컴포넌트 (반드시 사용)
|
||||
|
||||
| 컴포넌트 | 위치 | 용도 |
|
||||
|---|---|---|
|
||||
| `Badge` | `@shared/components/ui/badge` | 8 intent × 4 size, **className으로 색상 override 금지** |
|
||||
| `Button` | `@shared/components/ui/button` | 5 variant × 3 size (primary/secondary/ghost/outline/destructive) |
|
||||
| `Input` / `Select` / `Textarea` / `Checkbox` / `Radio` | `@shared/components/ui/` | 폼 요소 (Select는 aria-label 타입 강제) |
|
||||
| `TabBar` / `TabButton` | `@shared/components/ui/tabs` | underline / pill / segmented 3 variant |
|
||||
| `Card` / `CardHeader` / `CardTitle` / `CardContent` | `@shared/components/ui/card` | 4 variant |
|
||||
| `PageContainer` | `@shared/components/layout` | 페이지 루트 (size sm/md/lg + fullBleed) |
|
||||
| `PageHeader` | `@shared/components/layout` | 페이지 헤더 (icon + title + description + demo + actions) |
|
||||
| `Section` | `@shared/components/layout` | Card + CardHeader + CardTitle + CardContent 단축 |
|
||||
|
||||
### 카탈로그 기반 라벨/색상
|
||||
|
||||
분류 데이터는 `frontend/src/shared/constants/`의 19+ 카탈로그를 참조한다.
|
||||
중앙 레지스트리는 `catalogRegistry.ts`이며, 쇼케이스가 자동 열거한다.
|
||||
|
||||
```tsx
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
|
||||
|
||||
<Badge intent={getAlertLevelIntent(event.level)} size="sm">
|
||||
{getAlertLevelLabel(event.level, t, lang)}
|
||||
</Badge>
|
||||
```
|
||||
|
||||
ad-hoc 한글/영문 상태 문자열은 `getStatusIntent()` (statusIntent.ts) 사용.
|
||||
숫자 위험도는 `getRiskIntent(0~100)` 사용.
|
||||
|
||||
### CSS 작성 규칙
|
||||
|
||||
1. **인라인 색상 금지** — `style={{ backgroundColor: '#ef4444' }}` 같은 정적 색상은 작성 금지
|
||||
- 예외: 동적 데이터 기반 (`backgroundColor: meta.hex`, progress width `${value}%`)
|
||||
2. **하드코딩 Tailwind 색상 금지** — `bg-red-500/20 text-red-400` 같은 직접 작성 금지
|
||||
- 반드시 Badge intent 또는 카탈로그 API 호출
|
||||
3. **className override 정책**
|
||||
- ✅ 레이아웃/위치 보정: `<Badge intent="info" className="w-full justify-center">`
|
||||
- ❌ 색상/글자 크기 override: `<Badge intent="info" className="bg-red-500 text-xs">`
|
||||
4. **시맨틱 토큰 우선** — `theme.css @layer utilities`의 토큰 사용
|
||||
- `text-heading` / `text-label` / `text-hint` / `text-on-vivid` / `text-on-bright`
|
||||
- `bg-surface-raised` / `bg-surface-overlay` / `bg-card` / `bg-background`
|
||||
5. **!important 절대 금지** — `cn()` + `tailwind-merge`로 충돌 해결
|
||||
6. **`-webkit-` 벤더 prefix** — 수동 작성 CSS는 `backdrop-filter` 등 prefix 직접 추가 (Tailwind는 자동)
|
||||
|
||||
### 페이지 작성 표준 템플릿
|
||||
|
||||
```tsx
|
||||
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
|
||||
import { Button } from '@shared/components/ui/button';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
|
||||
import { Shield, Plus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function MyPage() {
|
||||
const { t, i18n } = useTranslation('common');
|
||||
const lang = i18n.language as 'ko' | 'en';
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Shield}
|
||||
iconColor="text-blue-400"
|
||||
title="페이지 제목"
|
||||
description="페이지 설명"
|
||||
actions={
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
|
||||
추가
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Section title="데이터 목록">
|
||||
<Badge intent={getAlertLevelIntent('HIGH')} size="sm">
|
||||
{getAlertLevelLabel('HIGH', t, lang)}
|
||||
</Badge>
|
||||
</Section>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 접근성 (a11y) 필수
|
||||
|
||||
- **`<button>`**: `type="button"` 명시 + 아이콘 전용은 `aria-label` 필수
|
||||
- **`<input>` / `<textarea>` / `<select>`**: `aria-label` 또는 `<label htmlFor>` 필수
|
||||
- **`Select` 컴포넌트**: TypeScript union type으로 `aria-label`/`aria-labelledby`/`title` 중 하나 컴파일 타임 강제
|
||||
- 위반 시 WCAG 2.1 Level A 위반 + axe DevTools 경고
|
||||
|
||||
### 변경 사이클
|
||||
|
||||
1. 디자인 변경이 필요하면 → **쇼케이스에서 먼저 미세조정** → 시각 검증
|
||||
2. 카탈로그 라벨/색상 변경 → `shared/constants/*` 또는 `variantMeta.ts`만 수정
|
||||
3. 컴포넌트 변형 추가 → `lib/theme/variants.ts` CVA에만 추가
|
||||
4. 실 페이지는 **컴포넌트만 사용**, 변경 시 자동 반영
|
||||
|
||||
### 금지 패턴 체크리스트
|
||||
|
||||
- ❌ `<Badge className="bg-red-500/20 text-red-400">` → `<Badge intent="critical">`
|
||||
- ❌ `<button className="bg-blue-600 ...">` → `<Button variant="primary">`
|
||||
- ❌ `<input className="bg-surface ...">` → `<Input>`
|
||||
- ❌ `<div className="p-5 space-y-4">` 페이지 루트 → `<PageContainer>`
|
||||
- ❌ `-m-4` negative margin 해킹 → `<PageContainer fullBleed>`
|
||||
- ❌ `style={{ color: '#ef4444' }}` 정적 색상 → 시맨틱 토큰 또는 카탈로그
|
||||
- ❌ `!important` → `cn()` 활용
|
||||
- ❌ 페이지 내 `const STATUS_COLORS = {...}` 로컬 재정의 → shared/constants 카탈로그
|
||||
|
||||
## System Flow 뷰어 (개발 단계용)
|
||||
|
||||
- **URL**: https://kcg-ai-monitoring.gc-si.dev/system-flow.html (메인 SPA와 별개)
|
||||
- **소스**: `frontend/system-flow.html` + `frontend/src/systemFlowMain.tsx` + `frontend/src/flow/`
|
||||
- **매니페스트**: `frontend/src/flow/manifest/` (10개 카테고리 JSON + meta.json + edges.json)
|
||||
- **노드 ID 명명**: `<category>.<snake_case>` (예: `output.event_generator`, `ui.parent_review`)
|
||||
- **딥링크**: `/system-flow.html#node=<node_id>` — 산출문서에서 노드 직접 참조
|
||||
- **가이드**: `docs/system-flow-guide.md` 참조
|
||||
|
||||
### `/version` 스킬 사후 처리 (필수)
|
||||
|
||||
`/version` 스킬을 실행하여 새 SemVer 버전이 결정되면, Codex는 이어서 다음 작업을 **자동으로** 수행한다 (`/version` 스킬 자체는 팀 공통 파일이라 직접 수정하지 않음):
|
||||
|
||||
1. **manifest 동기화**: `/version`이 결정한 새 버전을 `frontend/src/flow/manifest/meta.json`에 반영
|
||||
- `version`: 새 SemVer (예: `"1.2.0"`)
|
||||
- `updatedAt`: 현재 ISO datetime (`new Date().toISOString()`)
|
||||
- `releaseDate`: 오늘 날짜 (`YYYY-MM-DD`)
|
||||
2. **같은 커밋에 포함**: `frontend/src/flow/manifest/meta.json`을 `/version` 스킬이 만든 커밋에 amend하거나, `docs: VERSION-HISTORY 갱신 + system-flow manifest 동기화`로 통합 커밋
|
||||
3. **서버 archive는 CI/CD가 자동 처리**: 별도 작업 불필요. main 머지 후 Gitea Actions가 빌드 + dist 배포 + `/deploy/kcg-ai-monitoring-archive/system-flow/v{version}_{date}/`에 스냅샷 영구 보존
|
||||
|
||||
### 노드 ID 안정성
|
||||
|
||||
- **노드 ID는 절대 변경 금지** (산출문서가 참조하므로 깨짐)
|
||||
- 노드 제거 시 `status: 'deprecated'`로 마킹 (1~2 릴리즈 유지 후 삭제)
|
||||
- 새 노드 추가 시 `status: 'implemented'` 또는 `'planned'`
|
||||
@ -51,7 +51,6 @@ src/
|
||||
| [docs/page-workflow.md](docs/page-workflow.md) | 31개 페이지 역할 + 4개 업무 파이프라인 |
|
||||
| [docs/data-sharing-analysis.md](docs/data-sharing-analysis.md) | 데이터 공유 분석 + mock 통합 결과 |
|
||||
| [docs/next-refactoring.md](docs/next-refactoring.md) | 다음 단계 TODO (API 연동, 실시간, 코드 스플리팅) |
|
||||
| [docs/prediction-analysis.md](docs/prediction-analysis.md) | Prediction 모듈 구조/방향 심층 분석 (2026-04-17, opus 4.7 독립 리뷰) |
|
||||
|
||||
## SFR 요구사항 대응 현황
|
||||
|
||||
@ -68,7 +67,7 @@ src/
|
||||
| SFR-07 | 단일함정 순찰경로 | `/patrol-route` | UI 완료 |
|
||||
| SFR-08 | 다함정 경로최적화 | `/fleet-optimization` | UI 완료 |
|
||||
| SFR-09 | Dark Vessel 탐지 | `/dark-vessel` | UI 완료 |
|
||||
| SFR-10 | 어구 탐지 | `/gear-detection`, `/gear-collision`(V030) | UI 완료 |
|
||||
| SFR-10 | 어구 탐지 | `/gear-detection` | UI 완료 |
|
||||
| SFR-11 | 단속·탐지 이력 | `/enforcement-history` | UI 완료 |
|
||||
| SFR-12 | 모니터링 대시보드 | `/dashboard`, `/monitoring` | UI 완료 |
|
||||
| SFR-13 | 통계·성과 분석 | `/statistics` | UI 완료 |
|
||||
|
||||
@ -1,88 +1,18 @@
|
||||
# Backend (Spring Boot)
|
||||
|
||||
운영 배포 중 — rocky-211 `:18080` (`kcg-ai-backend` systemd).
|
||||
Phase 2에서 초기화 예정.
|
||||
|
||||
## 구성
|
||||
|
||||
- **Runtime**: Spring Boot 3.5.7 + Java 21
|
||||
- **DB**: PostgreSQL (kcgaidb) + Flyway V001~V030 (51 테이블)
|
||||
- **Auth**: Spring Security + JWT 쿠키 + BCrypt
|
||||
- **Cache**: Caffeine (권한 트리 10분 TTL)
|
||||
- **Permission**: 트리 기반 RBAC (47 리소스 × 5 operation)
|
||||
- **HTTP client**: `RestClient` + 명시적 `@Bean` 주입 (`predictionRestClient`, `signalBatchRestClient`)
|
||||
## 계획된 구성
|
||||
- Spring Boot 3.x + Java 21
|
||||
- PostgreSQL + Flyway
|
||||
- Spring Security + JWT
|
||||
- Caffeine 캐시
|
||||
- 트리 기반 RBAC 권한 체계 (wing 패턴)
|
||||
|
||||
## 책임
|
||||
- 자체 인증/권한/감사로그
|
||||
- 운영자 의사결정 (모선 확정/제외/학습)
|
||||
- prediction 분석 결과 조회 API (`/api/analysis/*`)
|
||||
- 관리자 화면 API
|
||||
|
||||
- 자체 인증/권한/감사 로그 + 관리자 API
|
||||
- 운영자 의사결정 (모선 확정·제외·학습, 어구 정체성 충돌 분류)
|
||||
- prediction 분석 결과 조회 (`/api/analysis/*`) + 이벤트 허브 (`/api/events`, `/api/alerts`)
|
||||
- prediction 실시간 상태 프록시 (`/api/prediction/*`)
|
||||
|
||||
## 빌드 · 배포
|
||||
|
||||
```bash
|
||||
# 로컬 실행
|
||||
./mvnw spring-boot:run
|
||||
|
||||
# 프로덕션 빌드
|
||||
./mvnw clean package -DskipTests
|
||||
# → target/kcg-ai-backend-*.jar
|
||||
|
||||
# 운영 배포 (수동)
|
||||
scp target/kcg-ai-backend-*.jar rocky-211:/opt/kcg-ai-backend/
|
||||
ssh rocky-211 "sudo systemctl restart kcg-ai-backend"
|
||||
```
|
||||
|
||||
## 필수 컴파일 설정 (PR #79 hotfix)
|
||||
|
||||
Spring 6.1 의 parameter-level `@Qualifier` 주입이 동작하려면 두 가지가 **필수**:
|
||||
|
||||
1. **`pom.xml`** — `maven-compiler-plugin` 의 `default-compile` 과 `default-testCompile` 양쪽 execution 에
|
||||
`<parameters>true</parameters>` 설정. 파라미터 이름을 바이트코드에 보존해야 `@Qualifier` resolve 가 가능.
|
||||
2. **`src/main/java/lombok.config`** — `lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier`
|
||||
설정. Lombok `@RequiredArgsConstructor` 가 필드의 `@Qualifier` 를 생성자 파라미터에 복사해야 Spring 이 인식.
|
||||
|
||||
둘 중 하나라도 누락되면 `PredictionProxyController` 같은 다중 `RestClient` bean 주입 컨트롤러가 기동 시점에
|
||||
`NoUniqueBeanDefinitionException` 으로 크래시 루프에 빠진다. 로컬에서 `./mvnw spring-boot:run` 실패는
|
||||
운영 restart 시 동일하게 재현되므로 **MR 범위 밖이어도 우선 해결**.
|
||||
|
||||
## 주요 패키지 구조
|
||||
|
||||
```
|
||||
src/main/java/gc/mda/kcg/
|
||||
├── config/ # RestClientConfig, SecurityConfig, OpenApi, CorsConfig, CaffeineConfig 등
|
||||
├── auth/ # LoginController, JWT, 비밀번호 정책
|
||||
├── permission/ # 트리 RBAC, @RequirePermission AOP, 캐시
|
||||
├── audit/ # @Auditable AOP, AuditLogService, AccessLogFilter
|
||||
├── domain/
|
||||
│ ├── analysis/ # VesselAnalysisController, GearCollisionController (V030), PredictionProxyController 등
|
||||
│ ├── fleet/ # ParentInferenceWorkflowController, FleetService
|
||||
│ ├── event/ # EventController, AlertController
|
||||
│ ├── enforcement/ # EnforcementController, EnforcementPlanController
|
||||
│ ├── master/ # MasterDataController (CodeMaster, GearTypeMaster, VesselPermit 등)
|
||||
│ ├── admin/ # AdminLogController, AdminStatsController, UserManagementController
|
||||
│ └── stats/ # StatsController (KPI 집계)
|
||||
└── Application.java
|
||||
```
|
||||
|
||||
모든 컨트롤러는 `controller → service → repository` 계층을 준수하며, 쓰기 액션은
|
||||
`@RequirePermission` + `@Auditable` 로 권한·감사 일관 적용.
|
||||
|
||||
## Flyway 마이그레이션
|
||||
|
||||
- 경로: [src/main/resources/db/migration/V001__*.sql ~ V030__gear_identity_collision.sql](src/main/resources/db/migration/)
|
||||
- 최신: **V030** (2026-04-17) — `gear_identity_collisions` 테이블 + `detection:gear-collision` 권한 트리
|
||||
- Flyway 자동 적용: Spring Boot 기동 시점
|
||||
|
||||
## 디렉토리 밖 의존성
|
||||
|
||||
- **prediction → kcgaidb**: prediction 이 직접 write. backend 는 HTTP 호출 없이 DB 조회로만 연동
|
||||
- **signal-batch**: `http://192.168.1.18:18090/signal-batch` (정적정보 보강, `signalBatchRestClient` 주입)
|
||||
- **prediction (FastAPI)**: `http://redis-211:18092` (실시간 상태/채팅 프록시 전용)
|
||||
|
||||
## 운영 체크리스트
|
||||
|
||||
1. 빌드 성공 → local `./mvnw spring-boot:run` 기동 확인 → curl `/api/auth/me` 200 확인
|
||||
2. scp 배포 → `systemctl restart kcg-ai-backend` → `journalctl -u kcg-ai-backend -n 100`
|
||||
3. 응답 확인: `curl -k https://kcg-ai-monitoring.gc-si.dev/api/analysis/vessels?hours=1`
|
||||
4. Flyway 에러 시: `backend.application` 로그에서 `Migration ... failed` 확인
|
||||
상세 설계: `.claude/plans/vast-tinkering-knuth.md`
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
-- V031: gear_group_parent_candidate_snapshots.candidate_source VARCHAR(30) → VARCHAR(100)
|
||||
--
|
||||
-- 원인: prediction/algorithms/gear_parent_inference.py 의 _rank_candidates 가
|
||||
-- candidate_source = ','.join(sorted(meta['sources'])) (line 875)
|
||||
-- 로 여러 source 라벨(CORRELATION / EPISODE / LABEL / LINEAGE / MATCH 등) 을 쉼표로
|
||||
-- join 해 저장한다. 모든 source 가 맞춰지면 약 39자(5개 라벨 + 쉼표) 에 달해
|
||||
-- VARCHAR(30) 를 초과하며 psycopg2.errors.StringDataRightTruncation 을 유발.
|
||||
--
|
||||
-- 영향: prediction 사이클의 gear_correlation 스테이지에서 `_insert_candidate_snapshots`
|
||||
-- 실패 → `gear_group_parent_candidate_snapshots` 테이블 갱신 전체 스킵.
|
||||
-- Phase 0-1 의 사이클 스테이지 에러 격리 덕분에 후속 스테이지(pair_detection /
|
||||
-- per-vessel analysis / upsert_results / 출력 모듈들)는 정상 완주.
|
||||
--
|
||||
-- 대안 검토:
|
||||
-- (A) VARCHAR(100) — 여유치. 채택.
|
||||
-- (B) TEXT — 제약 없음. 컬럼 의미가 "짧은 라벨 집합" 이라 VARCHAR 가 자연스러움.
|
||||
-- (C) 코드에서 [:30] trim — 의미 손실(source 목록 절단). 부적합.
|
||||
|
||||
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
|
||||
ALTER COLUMN candidate_source TYPE VARCHAR(100);
|
||||
@ -1,40 +0,0 @@
|
||||
-- V032: 불법 조업 이벤트 (IllegalFishingPattern) 메뉴·권한 seed
|
||||
--
|
||||
-- event_generator 가 생산하는 카테고리 중 "불법 조업" 관련 3종
|
||||
-- (GEAR_ILLEGAL / EEZ_INTRUSION / ZONE_DEPARTURE) 을 통합해서 보여주는
|
||||
-- READ 전용 대시보드. 운영자 액션(ack/status 변경) 은 /event-list 에서 수행.
|
||||
--
|
||||
-- Phase 0-2: prediction-analysis.md P1 권고의 "UI 미노출 탐지" 해소 중 첫 번째.
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 1. 권한 트리 / 메뉴 슬롯 (V024 이후 detection 그룹은 평탄화됨: parent_cd=NULL)
|
||||
-- nav_sort=920 은 chinaFishing(900) 과 gearCollision(950) 사이에 배치
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
INSERT INTO kcg.auth_perm_tree
|
||||
(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord,
|
||||
url_path, label_key, component_key, nav_sort, labels)
|
||||
VALUES
|
||||
('detection:illegal-fishing', NULL, '불법 조업 이벤트', 1, 45,
|
||||
'/illegal-fishing', 'nav.illegalFishing',
|
||||
'features/detection/IllegalFishingPattern', 920,
|
||||
'{"ko":"불법 조업 이벤트","en":"Illegal Fishing"}'::jsonb)
|
||||
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 2. 권한 부여
|
||||
-- READ 전용 페이지 — 모든 역할에 READ만 부여.
|
||||
-- 운영자가 ack/status 변경을 원하면 EventList(monitoring) 권한으로 이동.
|
||||
-- ADMIN 은 일관성을 위해 5 ops 전부 부여 (메뉴 등록/삭제 정도).
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||
SELECT r.role_sn, 'detection:illegal-fishing', op.oper_cd, 'Y'
|
||||
FROM kcg.auth_role r
|
||||
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
|
||||
WHERE r.role_cd = 'ADMIN'
|
||||
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||
|
||||
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||
SELECT r.role_sn, 'detection:illegal-fishing', 'READ', 'Y'
|
||||
FROM kcg.auth_role r
|
||||
WHERE r.role_cd IN ('OPERATOR', 'ANALYST', 'FIELD', 'VIEWER')
|
||||
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||
@ -1,46 +0,0 @@
|
||||
-- V033: 환적 의심 탐지 (TransshipmentDetection) 메뉴·권한 seed
|
||||
--
|
||||
-- prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과
|
||||
-- (is_transship_suspect=true) 를 전체 목록·집계·상세 수준으로 조회하는 READ 전용
|
||||
-- 대시보드. 기존 features/vessel/TransferDetection.tsx 는 선박 상세 수준이고,
|
||||
-- 이 페이지는 전체 목록·통계 운영 대시보드.
|
||||
--
|
||||
-- 참고: 실제 API `/api/analysis/transship` 는 VesselAnalysisController 에서 권한을
|
||||
-- `detection:dark-vessel` 로 가드 중이므로, 이 메뉴 READ 만으로는 API 호출 불가.
|
||||
-- 현재 운영자(OPERATOR/ANALYST/FIELD) 는 양쪽 READ 를 모두 가지므로 실용상 문제 없음.
|
||||
-- 향후 VesselAnalysisController.listTransshipSuspects 의 @RequirePermission 을
|
||||
-- `detection:transshipment` 로 교체하는 것이 권한 일관성상 바람직 (별도 MR).
|
||||
--
|
||||
-- Phase 0-3: prediction-analysis.md P1 권고의 "UI 미노출 탐지" 해소 중 두 번째.
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 1. 권한 트리 / 메뉴 슬롯 (detection 그룹 평탄화: parent_cd=NULL)
|
||||
-- nav_sort=910 은 chinaFishing(900) 과 illegalFishing(920) 사이
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
INSERT INTO kcg.auth_perm_tree
|
||||
(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord,
|
||||
url_path, label_key, component_key, nav_sort, labels)
|
||||
VALUES
|
||||
('detection:transshipment', NULL, '환적 의심 탐지', 1, 48,
|
||||
'/transshipment', 'nav.transshipment',
|
||||
'features/detection/TransshipmentDetection', 910,
|
||||
'{"ko":"환적 의심 탐지","en":"Transshipment"}'::jsonb)
|
||||
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 2. 권한 부여 — READ 전용 대시보드
|
||||
-- ADMIN: 5 ops 전부 (메뉴 관리 일관성)
|
||||
-- 나머지(OPERATOR/ANALYST/FIELD/VIEWER): READ
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||
SELECT r.role_sn, 'detection:transshipment', op.oper_cd, 'Y'
|
||||
FROM kcg.auth_role r
|
||||
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
|
||||
WHERE r.role_cd = 'ADMIN'
|
||||
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||
|
||||
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||
SELECT r.role_sn, 'detection:transshipment', 'READ', 'Y'
|
||||
FROM kcg.auth_role r
|
||||
WHERE r.role_cd IN ('OPERATOR', 'ANALYST', 'FIELD', 'VIEWER')
|
||||
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||
@ -4,20 +4,6 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 추가
|
||||
- **환적 의심 전용 탐지 페이지 신설 (Phase 0-3)** — `/transshipment` 경로에 READ 전용 대시보드 추가. prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과(is_transship_suspect=true)를 전체 목록·집계·상세 수준으로 조회. KPI 5장(Total + Transship tier CRITICAL/HIGH/MEDIUM + 종합 위험 CRITICAL) + DataTable 8컬럼 + 필터(hours/level/mmsi) + features JSON 상세. 기존 `/api/analysis/transship` + `getTransshipSuspects` 재사용해 backend 변경 없음. V033 마이그레이션으로 `detection:transshipment` 권한 트리 + 전 역할 READ 부여. (docs/prediction-analysis.md P1 UI 미노출 탐지 해소 — 2/2)
|
||||
- **불법 조업 이벤트 전용 페이지 신설 (Phase 0-2)** — `/illegal-fishing` 경로에 READ 전용 대시보드 추가. event_generator 가 생산하는 `GEAR_ILLEGAL`(G-01/G-05/G-06) + `EEZ_INTRUSION`(영해·접속수역) + `ZONE_DEPARTURE`(특정수역 진입) 3 카테고리를 한 화면에서 통합 조회. 심각도 KPI 5장 + 카테고리별 3장 + DataTable(7컬럼) + 필터(category/level/mmsi) + JSON features 상세 패널 + EventList 네비게이션. 기존 `/api/events` 를 category 다중 병렬 조회로 래핑하여 backend 변경 없이 구현. V032 마이그레이션으로 `detection:illegal-fishing` 권한 트리 + 전 역할 READ 부여 (운영자 처리 액션은 EventList 경유)
|
||||
|
||||
### 수정
|
||||
- **gear_group_parent_candidate_snapshots.candidate_source VARCHAR(30)→(100) 확장 (V031)** — prediction `gear_parent_inference` 가 여러 source 라벨을 쉼표로 join 한 값(최대 ~39자)이 VARCHAR(30) 제약을 넘어 매 사이클 `StringDataRightTruncation` 으로 gear correlation 스테이지 전체가 실패하던 기존 버그. Phase 0-1 (PR #83) 의 `logger.exception` 전환으로 풀 stacktrace 가 journal 에 찍히면서 원인 특정. backend JPA 엔티티 미참조로 재빌드 불필요, Flyway 자동 적용, prediction 재기동만으로 해소
|
||||
|
||||
### 변경
|
||||
- **Prediction 5분 사이클 스테이지 에러 경계 도입 (Phase 0-1)** — `prediction/pipeline/stage_runner.py` 신설해 `run_stage(name, fn, required=False)` 유틸 제공. `scheduler.py run_analysis_cycle()` 의 출력 6모듈(violation_classifier / event_generator / kpi_writer / stats_aggregate_hourly / stats_aggregate_daily / alert_dispatcher)을 한 try/except 로 묶던 구조를 스테이지별 독립 실행으로 분리, 한 모듈이 깨져도 다른 모듈이 계속 돌아가도록 개선. `upsert_results` 는 required=True 로 실패 시 사이클 abort. 내부 try/except 의 `logger.warning` 을 `logger.exception` 으로 업그레이드(fetch_dark_history / gear collision event promotion / group polygon / gear correlation / pair detection / chat cache)하여 `journalctl -u kcg-ai-prediction` 에서 stacktrace 로 실패 지점 즉시 특정 가능. (docs/prediction-analysis.md P1 권고)
|
||||
|
||||
### 문서
|
||||
- **Prediction 모듈 심층 분석 리포트 신설** — `docs/prediction-analysis.md` (9개 섹션, 250 라인). opus 4.7 독립 리뷰 관점에서 현재 17 알고리즘의 레이어 분리·5분 사이클 시퀀스·4대 도메인 커버리지를 평가하고, 6축(관심사 분리/재사용성/테스트 가능성/에러 격리/동시성/설정 가능성)으로 구조 채점 + P1~P4 개선 제안·임계값 전수표 제공
|
||||
- **루트·SFR 문서 drift 해소** — V001~V016 → V030 + 51 테이블, Python 3.9 → 3.11+, 14 → 17 알고리즘 모듈 실측 반영. SFR-10 에 GEAR_IDENTITY_COLLISION 패턴 + GearCollisionDetection 페이지 섹션 추가 (sfr-traceability/sfr-user-guide), `/gear-collision` 라우트 architecture.md 포함, system-flow-guide 노드 수 102→115 + V030 manifest 미반영 경고, backend/README "Phase 2 예정" 상태 → 실제 운영 구성 전면 재작성 (PR #79 hotfix 요구사항 명시)
|
||||
|
||||
## [2026-04-17.4]
|
||||
|
||||
### 수정
|
||||
|
||||
@ -318,7 +318,7 @@ deps 변경 → useMapLayers → RAF → overlay.setProps() (React 리렌
|
||||
|
||||
---
|
||||
|
||||
## 라우팅 구조 (27 보호 경로 + login)
|
||||
## 라우팅 구조 (26 보호 경로 + login)
|
||||
|
||||
`App.tsx`에서 `BrowserRouter` > `AuthProvider` > `Routes`로 구성된다.
|
||||
|
||||
@ -331,7 +331,6 @@ deps 변경 → useMapLayers → RAF → overlay.setProps() (React 리렌
|
||||
- `/enforcement-plan` — 단속계획 (SFR-06)
|
||||
- `/dark-vessel` — 무등화 선박 탐지 (SFR-09)
|
||||
- `/gear-detection` — 어구 탐지 (SFR-10)
|
||||
- `/gear-collision` — 어구 정체성 충돌 (SFR-10, V030 — 동일 어구 이름 × 복수 MMSI 공존 감지)
|
||||
- `/china-fishing` — 중국어선 탐지
|
||||
- `/patrol-route` — 순찰경로 (SFR-07)
|
||||
- `/fleet-optimization` — 함대 최적화 (SFR-08)
|
||||
|
||||
@ -1,250 +0,0 @@
|
||||
# Prediction 모듈 심층 분석 — 구조·방향 리뷰
|
||||
|
||||
**대상:** `prediction/` (Python 3.11+, FastAPI, APScheduler, 59 `.py` 파일)
|
||||
**작성일:** 2026-04-17
|
||||
**작성 관점:** opus 4.7 독립 리뷰 — 정밀도 튜닝이 아닌 **방향성·코드 구조**
|
||||
**전제:** 프로토타입·데모 단계. 정밀도 미흡은 인지된 상태.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — 3줄 요약
|
||||
|
||||
1. **뼈대는 튼튼하다.** 레이어 분리(algorithms/pipeline/output/db/cache), 순수함수 위주 알고리즘, 카테고리별 dedup 윈도우 분리까지 프로토타입치고는 일관된 설계.
|
||||
2. **약한 고리는 오케스트레이터.** [scheduler.py run_analysis_cycle()](../prediction/scheduler.py) 한 함수가 700+ 라인, 지역 try/except + `logger.warning` 로 흡수된 실패가 많아 "어디서 깨졌는지 조용히 묻힌다". 상태 누적(모듈 전역 `_transship_pair_history`)도 여기 묶여 있음.
|
||||
3. **커버리지 매트릭스는 4/4 이지만 UI 비대칭.** prediction 이 생산하는 결과 중 `ILLEGAL_FISHING_PATTERN` 이벤트·환적 의심은 DB·백엔드까지 도달하지만 전용 detection UI 가 없다. prediction 품질 개선과 무관하게 운영자가 쓸 수 없는 상태.
|
||||
|
||||
**권고 최우선 3가지** — 신규 알고리즘 추가보다 아래가 선행:
|
||||
- **P1:** 사이클 스테이지 단위 에러 경계(`_stage(...)` 유틸)로 교체해 실패 스테이지 명시 로깅 + 부분 실패 시에도 후속 단계 진행
|
||||
- **P1:** 하드코딩 임계값(MID prefix, 커버리지 박스, SOG band, 11 pattern 점수) 을 `correlation_param_models` 패턴처럼 DB/config 로 외부화
|
||||
- **P1:** 환적 전용 + ILLEGAL_FISHING_PATTERN 전용 프론트 페이지 추가 — 이미 DB·API 는 있음
|
||||
|
||||
---
|
||||
|
||||
## 1. 아키텍처 레이어 — 책임과 결합도
|
||||
|
||||
```
|
||||
prediction/
|
||||
├── config.py Pydantic Settings + qualified_table() — SSOT 설정
|
||||
├── scheduler.py APScheduler + run_analysis_cycle() (단일 엔트리)
|
||||
├── main.py FastAPI app + /health /status /chat 등
|
||||
├── fleet_tracker.py 상태 보유 (선단 레지스트리, 어구 정체성)
|
||||
├── time_bucket.py 안전 지연 12분 + backfill 3 버킷
|
||||
├── algorithms/ 17개 모듈 — 순수함수 중심
|
||||
├── pipeline/ 8개 모듈 — 7단계 분류 파이프라인
|
||||
├── output/ 5개 모듈 — event/violation/kpi/stats/alert
|
||||
├── db/ 4개 모듈 — snpdb / kcgdb / signal_api / partition_manager
|
||||
├── cache/ vessel_store.py — 24h sliding window 인메모리
|
||||
├── chat/ Ollama + RAG 스텁
|
||||
├── models/ result.py — AnalysisResult dataclass
|
||||
├── data/ monitoring zones JSON 정적 설정
|
||||
└── tests/ time_bucket / gear_parent_episode / gear_parent_inference 3종
|
||||
```
|
||||
|
||||
| 레이어 | 책임 | 결합도 평가 |
|
||||
|---|---|---|
|
||||
| `config.py` | 환경 + SQL identifier 검증 | ✅ SSOT, `qualified_table()` 로 스키마 주입 공격 방지 |
|
||||
| `algorithms/` | 탐지 로직 (순수) | ✅ 대부분 `df, params -> dict/tuple`. 상호 의존 적음 |
|
||||
| `pipeline/` | 7단계 sequential | ✅ `orchestrator.ChineseFishingVesselPipeline.run()` 이 DF 를 그대로 파이프 |
|
||||
| `output/` | 룰 엔진 + DB write | ✅ 룰을 lambda 리스트(event_generator.RULES)로 선언적 관리 |
|
||||
| `db/` | Connection pool + SQL | ⚠️ `kcgdb.upsert_results(results)` 가 한 트랜잭션에 전부 묶임 (파티션 unique index 활용은 적절) |
|
||||
| `cache/vessel_store.py` | 전역 싱글턴, 24h 궤적 인메모리 | ⚠️ 모듈 싱글턴 → 테스트 시 mock 어려움 |
|
||||
| `fleet_tracker.py` | 레지스트리·어구 정체성 상태 | ⚠️ 싱글턴 + 모듈 전역 캐시 |
|
||||
| `scheduler.py` | 전체 오케스트레이션 | ❌ **700+ 라인 모놀리식** — 아래 §2 상세 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 5분 사이클 시퀀스 — [scheduler.py:80-804](../prediction/scheduler.py#L80-L804)
|
||||
|
||||
사이클 전체가 **한 함수** 안에서 9단계로 진행된다.
|
||||
|
||||
| 단계 | 라인 | 역할 | 실패 처리 |
|
||||
|---|---|---|---|
|
||||
| 1. 증분 로드 | [97-106](../prediction/scheduler.py#L97-L106) | `snpdb.fetch_incremental()` → vessel_store merge | 전체 try/except 포함 |
|
||||
| 2. 정적 보강 | [108-112](../prediction/scheduler.py#L108-L112) | signal-batch API 호출 | 전체 try/except |
|
||||
| 3. 대상 선별 | [114-128](../prediction/scheduler.py#L114-L128) | SOG/COG 계산 + 0건 시 조기 return | ✅ 조기 반환 |
|
||||
| 4. 파이프라인 | [122-128](../prediction/scheduler.py#L122-L128) | `ChineseFishingVesselPipeline.run()` | 전체 try/except |
|
||||
| 5. 선단 분석 | [131-177](../prediction/scheduler.py#L131-L177) | fleet_tracker + gear_identity 충돌 감지 | ⚠️ 내부 try/except 로 warning, 전진 |
|
||||
| 5.5. 어구 그룹·상관·부모 추론 | [181-229](../prediction/scheduler.py#L181-L229) | polygon_builder + gear_correlation | ⚠️ 내부 try/except, 결과 없이 진행 |
|
||||
| 5.9. 쌍끌이 후보·판정 | [231-303](../prediction/scheduler.py#L231-L303) | pair_trawl STRONG/PROBABLE/SUSPECT | ⚠️ 내부 try/except |
|
||||
| 6. 개별 선박 분석 | [305-515](../prediction/scheduler.py#L305-L515) | AnalysisResult 생성 (파이프라인 통과자) | 루프 내 continue |
|
||||
| 6.5. 경량 분석 | [523-682](../prediction/scheduler.py#L523-L682) | 파이프라인 미통과 중국 MID — `compute_lightweight_risk_score` | 루프 내 try/except |
|
||||
| 7. 환적 의심 | [685-713](../prediction/scheduler.py#L685-L713) | `detect_transshipment` + `_transship_pair_history` 누적 | 전체 try/except |
|
||||
| 8. DB upsert | [716-717](../prediction/scheduler.py#L716-L717) | `kcgdb.upsert_results()` + `cleanup_old(48h)` | 전체 try/except |
|
||||
| 9. 출력 모듈 | [720-745](../prediction/scheduler.py#L720-L745) | violation_classifier → event_generator → kpi_writer → aggregate_hourly/daily → alert_dispatcher | ⚠️ **5개 단계를 한 try/except 로 묶음** → 어디서 실패했는지 단일 warning 으로 흡수 |
|
||||
| 10. 채팅 컨텍스트 캐싱 | [748-791](../prediction/scheduler.py#L748-L791) | Redis | 전체 try/except |
|
||||
|
||||
### 구조적 관찰
|
||||
|
||||
- **전체 try/except 는 있다** ([97](../prediction/scheduler.py#L97) ~ [803](../prediction/scheduler.py#L803)) — 치명 실패가 다음 사이클을 막지는 않음
|
||||
- **그러나 내부 스테이지가 너무 무겁다.** 9번째 출력 단계가 5개 모듈을 한 덩어리로 묶어 `logger.warning('output modules failed (non-fatal): %s', e)` 로 흡수. 어느 모듈이 깨졌는지 디버깅하려면 stacktrace 를 `logger.exception` 으로 바꿔야 함
|
||||
- **Lazy import** 가 스테이지마다 반복 (`from output.event_generator import ...` 등). 시작 시간 단축에는 도움이지만 import 오류가 **첫 사이클 실행 시점에만 드러남** — 배포 후 5분 지연 발견 경험 다수
|
||||
|
||||
### 권고 (사이클 구조 재정비)
|
||||
|
||||
```python
|
||||
def _stage(name: str, fn, *args, required=False, **kwargs):
|
||||
t0 = time.time()
|
||||
try:
|
||||
result = fn(*args, **kwargs)
|
||||
logger.info('stage %s ok in %.2fs', name, time.time() - t0)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception('stage %s failed: %s', name, e)
|
||||
if required:
|
||||
raise
|
||||
return None
|
||||
```
|
||||
|
||||
- 각 스테이지를 `_stage('pair_detection', _run_pair_detection, ...)` 로 감싸면 실패 스테이지 명시 로깅 + stacktrace + 부분 실패 허용 정책을 일관화.
|
||||
- 9번 단계의 5개 모듈은 각각 별도 `_stage(...)` 호출로 쪼갤 것.
|
||||
|
||||
---
|
||||
|
||||
## 3. 알고리즘 카탈로그 (17 모듈 × 담당 도메인)
|
||||
|
||||
| 파일 | 주 역할 | 입력 | 출력 | 주요 상수 |
|
||||
|---|---|---|---|---|
|
||||
| [dark_vessel.py](../prediction/algorithms/dark_vessel.py) | AIS gap + 11 패턴 점수화 | 선박 DF(timestamp, lat, lon, sog, cog) | (score 0~100, patterns[], tier) | GAP_SUSPICIOUS=6000s, VIOLATION=86400s, KR_COVERAGE box |
|
||||
| [spoofing.py](../prediction/algorithms/spoofing.py) | 텔레포트·극속·BD09 오프셋 | 선박 DF | spoofing_score 0~1 | EXTREME_SPEED=50kn (주석 기준), fishing max=25kn |
|
||||
| [risk.py](../prediction/algorithms/risk.py) | 종합 risk + 경량 risk 2종 | DF, zone, is_permitted, 외부 점수 | (risk 0~100, level) | tier: 70+=CRITICAL, 50+=HIGH, 30+=MEDIUM |
|
||||
| [fishing_pattern.py](../prediction/algorithms/fishing_pattern.py) | UCAF/UCFT gear SOG band | DF, gear | (ucaf, ucft) | PT 2.5~4.5, OT 2.0~4.0, GN 0.5~2.5 |
|
||||
| [transshipment.py](../prediction/algorithms/transshipment.py) | 5단계 필터 파이프라인 | DF targets, pair_history, zone_fn | list of dict | PROXIMITY ~220m, RENDEZVOUS 90min, WATCH 제외 |
|
||||
| [location.py](../prediction/algorithms/location.py) | zone 분류, haversine_nm, BD09 | (lat, lon) | zone, dist_nm | 12/24 NM 밴드 |
|
||||
| [gear_correlation.py](../prediction/algorithms/gear_correlation.py) | 멀티모델 EMA + streak | vessel_store, gear_groups, conn | UPDATE gear_correlation_scores | α_base=0.30, polygon=0.70 |
|
||||
| [gear_identity.py](../prediction/algorithms/gear_identity.py) | 공존 쌍 추출 (V030/PR #73) | gear_signals | collisions[] | CRITICAL_DIST=50km, COEXIST=3회 |
|
||||
| [gear_parent_inference.py](../prediction/algorithms/gear_parent_inference.py) | 어구 → 모선 assignment | gear_groups + tracks | parent 후보 + confidence | 2-pass: direct-match → candidates |
|
||||
| [gear_parent_episode.py](../prediction/algorithms/gear_parent_episode.py) | 에피소드 delineation (first_seen~last_seen) | gear_signals 시계열 | episodes[] | gap tolerance |
|
||||
| [gear_violation.py](../prediction/algorithms/gear_violation.py) | G-01~G-06 통합 판정 (DAR-03) | DF + zone + pair_result + permits | g_codes[], evidence, score | G-06=20pts, G-02=18pts, G-01=15pts |
|
||||
| [gear_name_rules.py](../prediction/algorithms/gear_name_rules.py) | 어구 이름 정규표현식 | string | parent_code (Optional) | regex set |
|
||||
| [pair_trawl.py](../prediction/algorithms/pair_trawl.py) | 쌍끌이 tier 분류 | DF×2, 6h | (pair_detected, tier, pair_type) | PROXIMITY=0.27NM, MIN_SYNC=2h |
|
||||
| [track_similarity.py](../prediction/algorithms/track_similarity.py) | DTW 궤적 유사도 | DF×2 | 0~1 | - |
|
||||
| [fleet.py](../prediction/algorithms/fleet.py) | leader/follower/independent | DF, tracker | role | - |
|
||||
| [polygon_builder.py](../prediction/algorithms/polygon_builder.py) | gear group convex hull | vessel_store, companies | 스냅샷[] | 시간버킷 |
|
||||
| [vessel_type_mapping.py](../prediction/algorithms/vessel_type_mapping.py) | fishery_code → vessel_type 폴백 | fishery_code | 'TRAWL'/'PT'/... | - |
|
||||
|
||||
**관찰:**
|
||||
- 대부분 **순수 함수** → 재사용·단위테스트 용이. 단 `gear_correlation.run_gear_correlation` 은 `conn` 을 받아 DB 를 직접 UPDATE 함 (알고리즘 + I/O 혼재)
|
||||
- **상수가 모듈 상단에 모여 있는 것은 좋으나 config 외부화는 안 됨**. 현장 운영자가 임계값을 바꾸려면 코드 배포 필요
|
||||
|
||||
---
|
||||
|
||||
## 4. 4대 도메인 커버리지 매트릭스
|
||||
|
||||
| 도메인 | 담당 파일 | 접근 방식 | 구조적 강점 | 구조적 공백 |
|
||||
|---|---|---|---|---|
|
||||
| **Dark Vessel** | `dark_vessel.py` + `risk.py` + `compute_dark_suspicion` | gap(100분 임계) + 11 패턴 점수 + 4-tier (CRITICAL≥70) | 패턴 분리(P1~P11) + 한국 커버리지 감점(−50pt)으로 자연 gap 구분 | MID/커버리지 박스 하드코딩. 감점 로직은 탐지 누락 위험(coverage box 바깥=자연 gap 가정) — 중국 EEZ 해역 근접 공해에서 의도적 OFF 가 감점 맞을 수 있음 |
|
||||
| **Gear Tracking** | `gear_correlation` + `gear_identity` + `gear_parent_inference` + `polygon_builder` | 어구 이름 regex → 공존/교체 분기 + 멀티모델 EMA + 모선 추론 + 폴리곤 | 공존(GEAR_IDENTITY_COLLISION)을 1순위 증거로 재정의 (V030). SAVEPOINT 로 PK 충돌 격리 | 어구 신호는 **이름 regex 의존**. 이름이 GEAR_PATTERN 에 맞지 않으면 파이프라인 전체 진입 실패 |
|
||||
| **Chinese Fishing** | `pipeline/` 7단계 + MID 412/413/414 필터 | preprocess → behavior → resample → feature → classify → cluster → seasonal + rule-based 분류 | 룰 + 군집으로 해석 가능 | MID prefix 하드코딩 2곳 중복. 전체 인구(55k)대비 500척만 파이프라인 통과 — 경량 분석(§6)으로 보완하나 여전히 <10% |
|
||||
| **Illegal Pattern** | `gear_violation` (G-01~G-06) + `event_generator.RULES` (15+ 룰) + `transshipment` 5단계 | 임계값 기반 독립 룰 × 카테고리별 dedup 윈도우 | 룰이 lambda 리스트로 선언적 | **UI 미노출** — DB `prediction_events` 카테고리 `ILLEGAL_*` 는 생산되나 전용 detection UI 없음. 운영자는 EventList(`/event-list`) 에서만 조회. 환적도 동일 문제 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 코드 구조 평가 (6축)
|
||||
|
||||
| 축 | 평가 | 근거 |
|
||||
|---|---|---|
|
||||
| **관심사 분리** | B+ | algorithms / pipeline / output / db 레이어는 깔끔. 단 scheduler 는 오케스트레이터가 아니라 **메가 함수** |
|
||||
| **재사용성** | A- | 17 알고리즘 모듈 중 ~15개가 순수함수. `run_gear_correlation` 만 conn 혼재 |
|
||||
| **테스트 가능성** | C+ | unit test 3개만 (time_bucket / gear_parent_episode / gear_parent_inference). `vessel_store` / `fleet_tracker` 싱글턴 → integration test 어려움 |
|
||||
| **에러 격리** | C | 사이클 전체 try/except + 내부 지역 try/except 혼재. 출력 5모듈이 한 덩어리 → 실패 지점 특정 불가 |
|
||||
| **동시성** | A- | `ThreadedConnectionPool(1,5)`, `max_instances=1` 스케줄러 — 단일 프로세스 가정 하에서 안전 |
|
||||
| **설정 가능성** | C- | 임계값 대부분 파일 상수. `correlation_param_models` 패턴만 DB 기반 (예외) |
|
||||
|
||||
### 주목할 잘된 점
|
||||
- **Dedup 윈도우 카테고리별 차등** ([event_generator.py:26-39](../prediction/output/event_generator.py#L26-L39)) — 5분 boundary 집단 만료를 피하기 위해 33/67/89/127/131/181/367분 등 **의도적으로 5의 배수 회피**. 룰 기반 탐지의 대표적인 "튜닝 knob" 이 코드에 명시.
|
||||
- **gear_identity 공존/교체 분기** ([fleet_tracker.py](../prediction/fleet_tracker.py) 트랜잭션 설계) — SAVEPOINT 로 부분 실패를 사이클 전체 abort 와 분리. 이전 13h 공백 사고의 재발 방지 설계가 구조에 반영됨.
|
||||
- **Lightweight path** — 파이프라인 통과 못한 중국 MID 를 경량 경로로 계속 커버 ([scheduler.py:523-682](../prediction/scheduler.py#L523-L682)). "정밀 vs 커버리지" 를 두 경로로 나누는 의사결정 자체는 탁월.
|
||||
|
||||
### 구조적 채무
|
||||
- **환경 분기 부재**: `config.py` 에 dev/prod 분기가 없음. `.env` 파일 하나에 의존. 로컬 실행 시 운영 DB 를 건드리는 위험 ([config.py:8-22](../prediction/config.py#L8-L22))
|
||||
- **상태 있는 모듈 전역 변수**: `_transship_pair_history`, `_last_run`, `_scheduler` ([scheduler.py:16-26](../prediction/scheduler.py#L16-L26)). 테스트 격리 어렵고, 재시작 시 pair 누적 상태 증발
|
||||
- **DB 쓰기 산재**: `kcgdb.upsert_results` / `save_group_snapshots` / `gear_correlation UPDATE` / `gear_identity UPSERT` / `prediction_events INSERT` 가 서로 다른 트랜잭션. 한 사이클 원자성 X — 의도적일 수 있으나 명시 설계 문서 없음
|
||||
|
||||
---
|
||||
|
||||
## 6. 방향성 진단 — 프로토타입 → MVP → 운영
|
||||
|
||||
### 지금 강점
|
||||
- **룰 기반 탐지를 탄탄히 다져둔 토대** — 임계값이 드러나 있고 dedup 설계가 명시적. 향후 ML overlay 를 얹을 때 "어디에 얹을지" 가 명확 (dark_suspicion score, transship score, gear_violation score 가 모두 연속값으로 산출)
|
||||
- **운영자 의사결정 통합 설계** — V030 GEAR_IDENTITY_COLLISION 에서 status(OPEN/REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE) 가 severity 재계산을 억제하는 패턴 — 사람 loop back 이 설계된 유일한 자리. 다른 도메인에도 이 패턴 확장 가능
|
||||
|
||||
### 지금 약점
|
||||
- **ML 부재** — sklearn/torch 없음. 2026 기준 프로토타입으로는 적절하나, sequence anomaly (dark gap 의 시계열 반복 패턴) 나 behavioral classifier 는 룰만으론 한계
|
||||
- **하드코딩 지대**: MID prefix(4개소), KR coverage box, SOG band, 11 패턴 점수, 5단계 transship 임계 — 모두 "이 프로젝트에서 튜닝해야 할 핫스팟" 인데 DB/config 분리 안 됨
|
||||
- **UI 비대칭**: `prediction_events.category='ILLEGAL_FISHING_PATTERN'` 이 생산되지만 전용 UI 없음. 환적도 동일. 결과적으로 **prediction 이 만드는 가치의 일부가 운영자에게 도달하지 못함**
|
||||
- **테스트 빈곤**: 17 알고리즘 중 3개만 유닛테스트. 사이클 단위 integration test 전혀 없음 — 사이클 회귀가 항상 운영 로그로만 드러남 (13h 공백 사고가 대표 사례)
|
||||
|
||||
---
|
||||
|
||||
## 7. 구조적 개선 제안 (우선순위별)
|
||||
|
||||
### P1 — 지금 해야 할 것 (운영 안정성)
|
||||
|
||||
1. **사이클 스테이지 단위 에러 경계** — `_stage(name, fn, required=False)` 유틸로 9번 출력 5모듈을 쪼갤 것. `logger.exception` 으로 stacktrace 보존. `required=True` 를 `fetch_incremental` 같은 fatal 에만 적용 → 실패 시 조기 반환
|
||||
2. **임계값 외부화** — `correlation_param_models` 패턴을 확장해 `detection_params` 테이블 신설 (algo_name, param_key, value, active_from, active_to). 배포 없이 해상도 튜닝 가능. 운영자 권한으로 접근 시 감사 로그
|
||||
3. **ILLEGAL_FISHING_PATTERN 전용 페이지** + **환적 전용 페이지** — 백엔드 API·DB 는 이미 존재. 프론트만 GearCollisionDetection 패턴으로 추가 (`PageContainer` + `DataTable` + `Badge intent`)
|
||||
4. **사이클 부분 원자성 명시** — DB 쓰기 경계 문서화 (어디까지가 한 트랜잭션인지). 최소한 [architecture.md](architecture.md) 또는 신설 `docs/prediction-transactions.md` 에 다이어그램
|
||||
|
||||
### P2 — 다음 (품질 확보)
|
||||
|
||||
5. **알고리즘 유닛테스트 커버리지** — 17 모듈 중 최소 10 개 (dark_vessel 11 패턴 / transshipment 5단계 / gear_violation 6 G-code / spoofing / risk) 에 fixture 기반 테스트. `tests/fixtures/` 에 AIS DF CSV 샘플
|
||||
6. **DB fixture integration test** — testcontainers-python 으로 PostgreSQL 띄워 한 사이클 실행 + 결과 테이블 assert. CI 에서 돌릴 수 있도록 데이터 10 척 x 1h 정도 경량
|
||||
7. **`vessel_store` / `fleet_tracker` 의존성 주입 개편** — 모듈 싱글턴 → `AnalysisContext` dataclass 로 명시 주입. 테스트 mock 가능
|
||||
8. **MID prefix·커버리지 box 를 `monitoring_zones` JSON 연장** — 이미 `data/monitoring_zones.json` 이 있음. 동일 포맷으로 `mid_prefixes.json` / `kr_coverage.json` 추가
|
||||
|
||||
### P3 — 중기 (가치 확장)
|
||||
|
||||
9. **ML overlay 타겟 설정** — dark_suspicion score (11 패턴 합산) 은 classifier training target 으로 최적. GCN/Transformer 로 **"gap 시퀀스가 의도적인가"** 를 학습. 룰 유지 + 게이트만 ML 로 대체 (shadow mode 로 비교)
|
||||
10. **correlation 파라미터 MLOps 연동** — `correlation_param_models` 를 MLflow 로 실험 기록 → 성능 좋은 모델 자동 active 전환
|
||||
11. **AIS 벤치마크 데이터셋** — 한중어업협정 906척 중 과거 단속 이력 있는 선박을 positive label. 현재 매칭률 53%+ 이므로 샘플 확보 가능. tier 별 precision/recall 산출
|
||||
|
||||
### P4 — 장기 (스케일)
|
||||
|
||||
12. **multi-process / async** — APScheduler + 단일 스레드 한계. 현 55k 선박 / 2.3M points / 110초 사이클에서 8k 중국 증가 + 한국 확장 시 5분 주기 내 완료 불가 예측. asyncio + ray / dask 로 스테이지 병렬
|
||||
13. **Event bus 분리** — 지금은 `prediction_events` INSERT 가 동기. outbox 패턴으로 비동기 분리 시 백엔드/프론트 실시간 push 기반 (WebSocket) 로 진화 가능
|
||||
|
||||
---
|
||||
|
||||
## 8. 부록 — 임계값 전수표 (외부화 우선순위)
|
||||
|
||||
| 위치 | 상수 | 현재값 | P1 외부화 | 비고 |
|
||||
|---|---|---|---|---|
|
||||
| [scheduler.py:28](../prediction/scheduler.py#L28) | `_KR_DOMESTIC_PREFIXES` | `('440','441')` | ✅ | 한국 MID |
|
||||
| [scheduler.py:140,247](../prediction/scheduler.py#L140) | 중국 MID prefix | `'412' '413' '414'` 하드코딩 2곳 | ✅ | `mid_prefixes.json` |
|
||||
| [scheduler.py:256](../prediction/scheduler.py#L256) | pair pool SOG band | `1.5 <= mean_sog <= 5.0` | ✅ | 조업 속력대 |
|
||||
| [dark_vessel.py:6-8](../prediction/algorithms/dark_vessel.py#L6-L8) | GAP 임계 3종 | 6000/10800/86400s | ✅ | 100분/3h/24h |
|
||||
| [dark_vessel.py:11-12](../prediction/algorithms/dark_vessel.py#L11-L12) | `_KR_COVERAGE_LAT/LON` | 32.0~39.5 / 124.0~132.0 | ✅ | AIS 수신 박스 |
|
||||
| [dark_vessel.py:257-359](../prediction/algorithms/dark_vessel.py#L257-L359) | 11 패턴 점수 P1~P11 | 10~30 pt 분산 | ⚠️ | 탐지 정책 튜닝 대상 |
|
||||
| [event_generator.py:26-39](../prediction/output/event_generator.py#L26-L39) | DEDUP_WINDOWS 12 카테고리 | 33~367분 | ⚠️ | 이미 의도적 |
|
||||
| [config.py:25-37](../prediction/config.py#L25-L37) | 파이프라인 주기 등 | 5/24/60/30/12/3 | ✅ env | `.env` 로 이미 가능 |
|
||||
| [transshipment.py](../prediction/algorithms/transshipment.py) | PROXIMITY / RENDEZVOUS | 0.002deg / 90min | ✅ | 환적 민감도 |
|
||||
| [pair_trawl.py](../prediction/algorithms/pair_trawl.py) | PROXIMITY / SOG_Δ / COG_Δ / MIN_SYNC | 0.27NM / 0.5kn / 10° / 2h | ⚠️ | tier 재분류 기준 |
|
||||
|
||||
**P1:** 배포 없이 튜닝 가능해야 할 것
|
||||
**⚠️:** 튜닝 자체가 탐지 정책 변경 → 릴리즈 노트 필요
|
||||
|
||||
---
|
||||
|
||||
## 9. 관련 파일 인덱스
|
||||
|
||||
- 오케스트레이터: [scheduler.py](../prediction/scheduler.py)
|
||||
- 설정: [config.py](../prediction/config.py)
|
||||
- 상태 컴포넌트: [fleet_tracker.py](../prediction/fleet_tracker.py), [cache/vessel_store.py](../prediction/cache/vessel_store.py)
|
||||
- 알고리즘: [algorithms/](../prediction/algorithms/)
|
||||
- 파이프라인: [pipeline/orchestrator.py](../prediction/pipeline/orchestrator.py)
|
||||
- 출력: [output/event_generator.py](../prediction/output/event_generator.py), [output/violation_classifier.py](../prediction/output/violation_classifier.py), [output/kpi_writer.py](../prediction/output/kpi_writer.py), [output/stats_aggregator.py](../prediction/output/stats_aggregator.py), [output/alert_dispatcher.py](../prediction/output/alert_dispatcher.py)
|
||||
- DB: [db/kcgdb.py](../prediction/db/kcgdb.py), [db/snpdb.py](../prediction/db/snpdb.py), [db/partition_manager.py](../prediction/db/partition_manager.py)
|
||||
|
||||
연관 운영 문서:
|
||||
- [architecture.md](architecture.md) — 프론트엔드 아키텍처
|
||||
- [sfr-traceability.md](sfr-traceability.md) — SFR 매트릭스
|
||||
- [system-flow-guide.md](system-flow-guide.md) — system-flow 노드 명세
|
||||
- `backend/README.md` — 백엔드 구성 (V030 + PR #79 hotfix 요구사항 명시)
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 일자 | 내용 |
|
||||
|---|---|
|
||||
| 2026-04-17 | 초판 — opus 4.7 독립 리뷰. 구조/방향 중심 + 우선순위별 개선 제안 |
|
||||
@ -13,9 +13,9 @@
|
||||
| 레이어 | 기술 | 상태 |
|
||||
|-------|------|------|
|
||||
| Frontend | React 19 + TypeScript 5.9 + Vite 8 + Tailwind CSS 4 + Zustand 5 + MapLibre GL 5 + deck.gl 9 + ECharts 6 + react-i18next | 운영 배포 (rocky-211 nginx) |
|
||||
| Backend | Spring Boot 3.5.7 + Java 21 + PostgreSQL 14.19 + Flyway V001~V030 + Spring Security + JWT + Caffeine + 트리 RBAC | 운영 배포 (rocky-211 :18080) |
|
||||
| Prediction | Python 3.11+ + FastAPI + APScheduler, 17 알고리즘 모듈 + 7단계 분류 파이프라인 + 5 출력/룰 모듈 | 운영 배포 (redis-211 :18092, 5분 주기) |
|
||||
| Database | PostgreSQL `kcgaidb` / 51 테이블 / schema `kcg` + snpdb(AIS 원천) | 운영 |
|
||||
| Backend | Spring Boot 3.5.7 + Java 21 + PostgreSQL 14.19 + Flyway V001~V029 + Spring Security + JWT + Caffeine + 트리 RBAC | 운영 배포 (rocky-211 :18080) |
|
||||
| Prediction | Python 3.11+ + FastAPI + APScheduler, 14 알고리즘, 7단계 분류 파이프라인 | 운영 배포 (redis-211 :18092, 5분 주기) |
|
||||
| Database | PostgreSQL `kcgaidb` / 48 테이블 / schema `kcg` + snpdb(AIS 원천) | 운영 |
|
||||
| Design System | `/design-system.html` 쇼케이스 SSOT + `shared/constants/` 25개 카탈로그 + `shared/components/ui/` 9개 공통 컴포넌트 | SSOT 전영역 준수 (2026-04-17 PR #C 완료) |
|
||||
| i18n | 10 네임스페이스 × ko/en, `common.json` 에 aria/error/dialog/message 54키 추가 | alert/confirm/aria-label 하드코딩 제거 완료 (2026-04-17 PR #B) |
|
||||
|
||||
@ -87,7 +87,7 @@ Frontend ← Backend /api/analysis/* + /api/events + /api/alerts + ... (65+ API)
|
||||
| SFR-07 | AI 경비함정 단일 함정 순찰·경로 | PatrolRoute | 🔲 Mock | - |
|
||||
| SFR-08 | AI 경비함정 다함정 협력형 경로 | FleetOptimization | 🔲 Mock | - |
|
||||
| SFR-09 | 불법 어선 패턴 탐지 (Dark Vessel) | DarkVesselDetection, TransferDetection | ✅ /api/analysis/* | ✅ Dark 11패턴 + Transship 5단계 |
|
||||
| SFR-10 | 불법 어망·어구 탐지 및 관리 | GearDetection, GearIdentification, GearCollisionDetection(V030) | ✅ /api/vessel-analysis/groups + /api/analysis/gear-detections + /api/analysis/gear-collisions | ✅ DAR-03 G-01~G-06 + pair tier + GEAR_IDENTITY_COLLISION(PR #73) |
|
||||
| SFR-10 | 불법 어망·어구 탐지 및 관리 | GearDetection, GearIdentification | ✅ /api/vessel-analysis/groups + /api/analysis/gear-detections | ✅ DAR-03 G-01~G-06 + pair tier |
|
||||
| SFR-11 | 단속·탐지 이력 관리 | EnforcementHistory, EventList | ✅ /api/events + /api/enforcement/records | ✅ prediction_events |
|
||||
| SFR-12 | 모니터링 및 경보 현황판 | Dashboard, MonitoringDashboard, ChinaFishing | ✅ /api/stats + /api/alerts + /api/analysis/* | ✅ prediction_kpi_realtime + stats |
|
||||
| SFR-13 | 통계·지표·성과 분석 | Statistics | ✅ /api/stats (daily/monthly/hourly) | ✅ prediction_stats_* |
|
||||
@ -257,16 +257,14 @@ Frontend ← Backend /api/analysis/* + /api/events + /api/alerts + ... (65+ API)
|
||||
- G-06 쌍끌이 공조 — **tier 분류**: STRONG / PROBABLE / SUSPECT
|
||||
- 페어 탐색 `find_pair_candidates` (bbox + 궤적 유사도 2차)
|
||||
- 한중어업협정 906척 NAME_EXACT + NAME_FUZZY 매칭 53%+
|
||||
- **GEAR_IDENTITY_COLLISION (V030/PR #73)** — 동일 어구 이름이 서로 다른 MMSI 로 동일 사이클 내 공존 감지 → `gear_identity_collisions` UPSERT(name, mmsi_lo, mmsi_hi), CRITICAL/HIGH/MEDIUM/LOW severity 분류. 이전 "교체(sequential)" 로 오해하던 케이스를 "어구 복제/스푸핑 증거" 로 재정의. SAVEPOINT + try/except 로 `gear_correlation_scores_pkey` 충돌 격리
|
||||
|
||||
**백엔드 연동 ✅**:
|
||||
- `/api/vessel-analysis/groups` + `/groups/{key}/detail|correlations|candidates/{mmsi}/metrics|resolve` — 모선 워크플로우 (VesselAnalysisGroupService, 2026-04-17 PARENT_RESOLVE @Auditable 추가)
|
||||
- `/api/analysis/gear-detections` (2026-04-16.6 신설) — 자동 탐지 결과 MMSI 중복 제거
|
||||
- `/api/analysis/gear-collisions` (V030, 4 엔드포인트) — GET list/stats/{id} + POST {id}/resolve (REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE/REOPEN), `@Auditable GEAR_COLLISION_RESOLVE`
|
||||
- 24h 궤적 리플레이 (gearReplayStore + useGearReplayLayers + TripsLayer)
|
||||
- GearDetailPanel: 후보 클릭 → 모선 확정/제외 UI
|
||||
|
||||
**마스터**: fishery_permit_cn (V029, 29 컬럼) + fleet_vessels 확장 (permit_year/fishery_code) + gear_identity_collisions (V030)
|
||||
**마스터**: fishery_permit_cn (V029, 29 컬럼) + fleet_vessels 확장 (permit_year/fishery_code)
|
||||
|
||||
---
|
||||
|
||||
@ -414,7 +412,7 @@ Frontend ← Backend /api/analysis/* + /api/events + /api/alerts + ... (65+ API)
|
||||
| SFR-07 | `features/patrol/PatrolRoute.tsx` |
|
||||
| SFR-08 | `features/patrol/FleetOptimization.tsx` |
|
||||
| SFR-09 | `features/detection/DarkVesselDetection.tsx`, `features/detection/components/DarkDetailPanel.tsx`, `features/vessel/TransferDetection.tsx`, `prediction/algorithms/dark_vessel.py`, `spoofing.py`, `transship.py`, `risk.py` |
|
||||
| SFR-10 | `features/detection/GearDetection.tsx`, `GearIdentification.tsx`, `GearCollisionDetection.tsx`(V030), `features/detection/components/GearDetailPanel.tsx`, `GearReplayController.tsx`, `prediction/algorithms/pair_trawl.py`, `gear_violation.py`, `gear_identity.py`(V030), `gear_correlation.py`, `gear_parent_inference.py`, `vessel_type_mapping.py`, `backend/.../analysis/VesselAnalysisGroupService.java`, `GearCollisionController+Service.java`(V030) |
|
||||
| SFR-10 | `features/detection/GearDetection.tsx`, `GearIdentification.tsx`, `features/detection/components/GearDetailPanel.tsx`, `GearReplayController.tsx`, `prediction/algorithms/pair_trawl.py`, `gear_violation.py`, `vessel_type_mapping.py`, `backend/.../analysis/VesselAnalysisGroupService.java` |
|
||||
| SFR-11 | `features/enforcement/EnforcementHistory.tsx`, `EventList.tsx`, `backend/.../event/EventController+Service.java`, `AlertService.java`, `enforcement/EnforcementService.java` |
|
||||
| SFR-12 | `features/dashboard/Dashboard.tsx`, `features/monitoring/MonitoringDashboard.tsx`, `features/detection/ChinaFishing.tsx`, `features/detection/components/VesselMiniMap.tsx`, `VesselAnomalyPanel.tsx`, `backend/.../analysis/VesselAnalysisController+Service.java` |
|
||||
| SFR-13 | `features/statistics/Statistics.tsx`, `ReportManagement.tsx`, `backend/.../stats/`, `admin/AdminStatsService.java` |
|
||||
@ -485,7 +483,6 @@ Frontend ← Backend /api/analysis/* + /api/events + /api/alerts + ... (65+ API)
|
||||
| AdminLogController | (직접 Repository) | SFR-01 |
|
||||
| VesselAnalysisController | VesselAnalysisService | SFR-09, 12 |
|
||||
| VesselAnalysisProxyController | VesselAnalysisGroupService (+`@Auditable PARENT_RESOLVE`) | SFR-10 |
|
||||
| GearCollisionController (V030) | GearCollisionService (+`@Auditable GEAR_COLLISION_RESOLVE`) | SFR-10 |
|
||||
| PredictionProxyController | RestClient `@Bean predictionRestClient` | SFR-03, 20 |
|
||||
| EventController | EventService | SFR-11, 12 |
|
||||
| AlertController | AlertService | SFR-12, 17 |
|
||||
|
||||
@ -450,37 +450,6 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
||||
|
||||
---
|
||||
|
||||
### 어구 정체성 충돌 (V030, 2026-04-17 추가)
|
||||
|
||||
**메뉴 위치:** 탐지/분석 > 어구 정체성 충돌
|
||||
**URL:** `/gear-collision`
|
||||
**접근 권한:** ADMIN, OPERATOR, ANALYST, FIELD, VIEWER (READ) / ADMIN, OPERATOR (UPDATE)
|
||||
|
||||
**화면 설명:**
|
||||
동일한 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클에 동시 송출되는 공존 케이스를 탐지해
|
||||
어구 복제/스푸핑 증거로 기록·분류하는 화면입니다. 이전에는 "MMSI 교체"로 오해해 덮어쓰는 바람에
|
||||
`gear_correlation_scores_pkey` 제약 충돌로 prediction 사이클 전체가 실패하던 이슈(PR #73)를
|
||||
"공존 패턴"으로 재정의하면서 신설되었습니다.
|
||||
|
||||
**주요 기능:**
|
||||
- 충돌 쌍(name, MMSI 저/고) 목록 + 공존 누적 횟수 + 양 위치 최대 거리 + severity tier
|
||||
- 운영자 분류 워크플로우: OPEN → REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE (REOPEN 가능)
|
||||
- severity 자동 산정: CRITICAL(거리≥50km OR 공존≥3회) / HIGH(거리≥10km OR 공존≥2회) / MEDIUM / LOW
|
||||
- 상세 패널 — 양 MMSI 최근 궤적, 어구 이름 파싱(선박명+어구코드), 매칭된 모선(fleet_vessels)
|
||||
|
||||
**구현 완료 (V030 / PR #73, 2026-04-17):**
|
||||
- ✅ `gear_identity_collisions` 테이블 + UNIQUE(name, mmsi_lo, mmsi_hi) 제약 + 5 인덱스
|
||||
- ✅ prediction `algorithms/gear_identity.py` 순수함수 + `fleet_tracker.track_gear_identity()` 공존/교체 분기
|
||||
- ✅ 백엔드 4 엔드포인트 `/api/analysis/gear-collisions` + `GEAR_COLLISION_RESOLVE` @Auditable
|
||||
- ✅ 프론트 페이지 `GearCollisionDetection.tsx` (DataTable + KPI 5장 + SidePanel + 상태/심각도 필터)
|
||||
- ✅ 이벤트 허브 연동 — HIGH/CRITICAL 은 `prediction_events` 에 자동 등록 (dedup 367분)
|
||||
|
||||
**향후 구현 예정:**
|
||||
- 🔲 FALSE_POSITIVE 반복 쌍 자동 화이트리스트 (v2 학습 피드백 루프)
|
||||
- 🔲 KPI 테이블 (`prediction_stats_*`) 에 severity 별 집계 반영
|
||||
|
||||
---
|
||||
|
||||
## SFR-11: 단속/탐지 이력 관리
|
||||
|
||||
**메뉴 위치:** 단속/이력 > 단속/탐지 이력
|
||||
|
||||
@ -6,15 +6,10 @@ KCG AI Monitoring 시스템 워크플로우 플로우차트 뷰어 사용법.
|
||||
|
||||
`/system-flow.html`은 snpdb 5분 원천 궤적 수집부터 prediction 분석, 이벤트 생성, 운영자 의사결정까지 시스템 전체 데이터 흐름을 노드/엣지로 시각화한 **개발 단계 활용 페이지**입니다.
|
||||
|
||||
- 115개 노드 + 133개 엣지 (manifest 현재 상태, `meta.json` 은 아직 v1.0.0/2026-04-07 로 미갱신)
|
||||
- 102개 노드 + 133개 엣지 (v1.0.0 기준)
|
||||
- 메인 SPA(`/`)와 완전 분리된 별도 React 앱
|
||||
- 메뉴/링크 노출 없음 — 직접 URL 접근만
|
||||
|
||||
> ⚠️ **V030 미반영 경고**: 2026-04-17 V030 로 추가된 GEAR_IDENTITY_COLLISION 파이프라인 (
|
||||
> `algo.gear_identity_collision`, `storage.gear_identity_collisions`, `api.gear_collisions_*`,
|
||||
> `ui.gear_collision`, `decision.gear_collision_resolve`) 노드가 아직 manifest 에 등록되지
|
||||
> 않았다. 다음 `/version` 릴리즈 시 매니페스트 동기화 필요.
|
||||
|
||||
## 접근 URL
|
||||
|
||||
- **운영**: https://kcg-ai-monitoring.gc-si.dev/system-flow.html
|
||||
|
||||
@ -42,12 +42,6 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
|
||||
'features/detection/GearCollisionDetection': lazy(() =>
|
||||
import('@features/detection').then((m) => ({ default: m.GearCollisionDetection })),
|
||||
),
|
||||
'features/detection/IllegalFishingPattern': lazy(() =>
|
||||
import('@features/detection').then((m) => ({ default: m.IllegalFishingPattern })),
|
||||
),
|
||||
'features/detection/TransshipmentDetection': lazy(() =>
|
||||
import('@features/detection').then((m) => ({ default: m.TransshipmentDetection })),
|
||||
),
|
||||
// ── 단속·이벤트 ──
|
||||
'features/enforcement/EnforcementHistory': lazy(() =>
|
||||
import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })),
|
||||
|
||||
@ -1,391 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Ban, RefreshCw, ExternalLink } from 'lucide-react';
|
||||
|
||||
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { Button } from '@shared/components/ui/button';
|
||||
import { Input } from '@shared/components/ui/input';
|
||||
import { Select } from '@shared/components/ui/select';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
|
||||
import {
|
||||
ILLEGAL_FISHING_CATEGORIES,
|
||||
listIllegalFishingEvents,
|
||||
type IllegalFishingCategory,
|
||||
type IllegalFishingPatternPage,
|
||||
} from '@/services/illegalFishingPatternApi';
|
||||
import type { PredictionEvent } from '@/services/event';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
/**
|
||||
* 불법 조업 이벤트 — event_generator 가 생산하는 카테고리 중 "불법 조업" 관련 3종을
|
||||
* 묶어 한 화면에서 조회하는 대시보드.
|
||||
*
|
||||
* GEAR_ILLEGAL : G-01 수역-어구 / G-05 고정어구 drift / G-06 쌍끌이 공조
|
||||
* EEZ_INTRUSION : 영해 침범 / 접속수역 고위험
|
||||
* ZONE_DEPARTURE : 특정수역 진입 (risk ≥ 40)
|
||||
*
|
||||
* 운영자 액션(확인/상태변경/단속 등록)은 /event-list 이벤트 목록에서 수행.
|
||||
* 이 페이지는 **READ 전용** 대시보드.
|
||||
*/
|
||||
|
||||
const LEVEL_OPTIONS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const;
|
||||
const DEFAULT_SIZE = 200;
|
||||
|
||||
export function IllegalFishingPattern() {
|
||||
const { t } = useTranslation('detection');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language) as 'ko' | 'en';
|
||||
|
||||
const [page, setPage] = useState<IllegalFishingPatternPage | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<IllegalFishingCategory | ''>('');
|
||||
const [levelFilter, setLevelFilter] = useState<string>('');
|
||||
const [mmsiFilter, setMmsiFilter] = useState('');
|
||||
const [selected, setSelected] = useState<PredictionEvent | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await listIllegalFishingEvents({
|
||||
category: categoryFilter || undefined,
|
||||
level: levelFilter || undefined,
|
||||
vesselMmsi: mmsiFilter || undefined,
|
||||
size: DEFAULT_SIZE,
|
||||
});
|
||||
setPage(result);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : t('illegalPattern.error.loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [categoryFilter, levelFilter, mmsiFilter, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const rows = page?.content ?? [];
|
||||
const levelCount = (code: string) => page?.byLevel?.[code] ?? 0;
|
||||
const categoryCount = (code: string) => page?.byCategory?.[code] ?? 0;
|
||||
|
||||
const cols: DataColumn<PredictionEvent & Record<string, unknown>>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'occurredAt',
|
||||
label: t('illegalPattern.columns.occurredAt'),
|
||||
width: '140px',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-muted-foreground text-[10px]">{formatDateTime(v as string)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'level',
|
||||
label: t('illegalPattern.columns.level'),
|
||||
width: '90px',
|
||||
align: 'center',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<Badge intent={getAlertLevelIntent(v as string)} size="sm">
|
||||
{getAlertLevelLabel(v as string, tc, lang)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: t('illegalPattern.columns.category'),
|
||||
width: '130px',
|
||||
align: 'center',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<Badge intent="info" size="sm">
|
||||
{t(`illegalPattern.category.${v as string}`, { defaultValue: v as string })}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'title',
|
||||
label: t('illegalPattern.columns.title'),
|
||||
minWidth: '260px',
|
||||
render: (v) => <span className="text-label">{v as string}</span>,
|
||||
},
|
||||
{
|
||||
key: 'vesselMmsi',
|
||||
label: t('illegalPattern.columns.mmsi'),
|
||||
width: '110px',
|
||||
render: (v, row) => (
|
||||
<span className="font-mono text-[10px] text-cyan-600 dark:text-cyan-400">
|
||||
{(v as string) || '-'}
|
||||
{row.vesselName ? <span className="text-hint ml-1">({row.vesselName})</span> : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'zoneCode',
|
||||
label: t('illegalPattern.columns.zone'),
|
||||
width: '130px',
|
||||
render: (v) => <span className="text-hint text-[10px]">{(v as string) || '-'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: t('illegalPattern.columns.status'),
|
||||
width: '90px',
|
||||
align: 'center',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<Badge intent={(v as string) === 'NEW' ? 'warning' : 'muted'} size="sm">
|
||||
{t(`illegalPattern.status.${v as string}`, { defaultValue: v as string })}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, tc, lang],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Ban}
|
||||
iconColor="text-red-600 dark:text-red-400"
|
||||
title={t('illegalPattern.title')}
|
||||
description={t('illegalPattern.desc')}
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{t('illegalPattern.refresh')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Card variant="default">
|
||||
<CardContent className="text-destructive text-xs py-2">{error}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Section title={t('illegalPattern.stats.title')}>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
<StatCard label={t('illegalPattern.stats.total')} value={page?.content.length ?? 0} />
|
||||
<StatCard
|
||||
label={getAlertLevelLabel('CRITICAL', tc, lang)}
|
||||
value={levelCount('CRITICAL')}
|
||||
intent="critical"
|
||||
/>
|
||||
<StatCard
|
||||
label={getAlertLevelLabel('HIGH', tc, lang)}
|
||||
value={levelCount('HIGH')}
|
||||
intent="warning"
|
||||
/>
|
||||
<StatCard
|
||||
label={getAlertLevelLabel('MEDIUM', tc, lang)}
|
||||
value={levelCount('MEDIUM')}
|
||||
intent="info"
|
||||
/>
|
||||
<StatCard
|
||||
label={getAlertLevelLabel('LOW', tc, lang)}
|
||||
value={levelCount('LOW')}
|
||||
intent="muted"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t('illegalPattern.byCategory.title')}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
{ILLEGAL_FISHING_CATEGORIES.map((code) => (
|
||||
<Card key={code} variant="default">
|
||||
<CardContent className="py-2 px-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-heading">
|
||||
{t(`illegalPattern.category.${code}`)}
|
||||
</div>
|
||||
<div className="text-[10px] text-hint">
|
||||
{t(`illegalPattern.categoryDesc.${code}`)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-heading">{categoryCount(code)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t('illegalPattern.list.title')}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
|
||||
<Select
|
||||
aria-label={t('illegalPattern.filters.category')}
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value as IllegalFishingCategory | '')}
|
||||
>
|
||||
<option value="">{t('illegalPattern.filters.allCategory')}</option>
|
||||
{ILLEGAL_FISHING_CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`illegalPattern.category.${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
aria-label={t('illegalPattern.filters.level')}
|
||||
value={levelFilter}
|
||||
onChange={(e) => setLevelFilter(e.target.value)}
|
||||
>
|
||||
<option value="">{t('illegalPattern.filters.allLevel')}</option>
|
||||
{LEVEL_OPTIONS.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{getAlertLevelLabel(l, tc, lang)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
aria-label={t('illegalPattern.filters.mmsi')}
|
||||
placeholder={t('illegalPattern.filters.mmsi')}
|
||||
value={mmsiFilter}
|
||||
onChange={(e) => setMmsiFilter(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<Badge intent="info" size="sm">
|
||||
{t('illegalPattern.filters.limit')} · {DEFAULT_SIZE}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 && !loading ? (
|
||||
<p className="text-hint text-xs py-4 text-center">{t('illegalPattern.list.empty')}</p>
|
||||
) : (
|
||||
<DataTable
|
||||
data={rows as (PredictionEvent & Record<string, unknown>)[]}
|
||||
columns={cols}
|
||||
pageSize={20}
|
||||
showSearch={false}
|
||||
showExport={false}
|
||||
showPrint={false}
|
||||
onRowClick={(row) => setSelected(row as PredictionEvent)}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{selected && (
|
||||
<Section title={t('illegalPattern.detail.title')}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||
<div className="space-y-1.5">
|
||||
<DetailRow
|
||||
label={t('illegalPattern.columns.occurredAt')}
|
||||
value={formatDateTime(selected.occurredAt)}
|
||||
/>
|
||||
<DetailRow label={t('illegalPattern.columns.category')} value={selected.category} />
|
||||
<DetailRow label={t('illegalPattern.columns.level')} value={selected.level} />
|
||||
<DetailRow label={t('illegalPattern.columns.title')} value={selected.title} />
|
||||
<DetailRow
|
||||
label={t('illegalPattern.columns.mmsi')}
|
||||
value={selected.vesselMmsi ?? '-'}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('illegalPattern.detail.vesselName')}
|
||||
value={selected.vesselName ?? '-'}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('illegalPattern.columns.zone')}
|
||||
value={selected.zoneCode ?? '-'}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('illegalPattern.detail.location')}
|
||||
value={
|
||||
selected.lat != null && selected.lon != null
|
||||
? `${selected.lat.toFixed(4)}, ${selected.lon.toFixed(4)}`
|
||||
: '-'
|
||||
}
|
||||
/>
|
||||
<DetailRow label={t('illegalPattern.columns.status')} value={selected.status} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{selected.detail && (
|
||||
<p className="text-xs text-label border-l-2 border-border pl-2">
|
||||
{selected.detail}
|
||||
</p>
|
||||
)}
|
||||
{selected.features && Object.keys(selected.features).length > 0 && (
|
||||
<div>
|
||||
<div className="text-[10px] text-hint mb-1">
|
||||
{t('illegalPattern.detail.features')}
|
||||
</div>
|
||||
<pre className="bg-surface-raised text-[10px] text-label p-2 overflow-auto max-h-48">
|
||||
{JSON.stringify(selected.features, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelected(null)}>
|
||||
{t('illegalPattern.detail.close')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<ExternalLink className="w-3.5 h-3.5" />}
|
||||
onClick={() => {
|
||||
window.location.href = `/event-list?category=${selected.category}&mmsi=${selected.vesselMmsi ?? ''}`;
|
||||
}}
|
||||
>
|
||||
{t('illegalPattern.detail.openEventList')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 내부 컴포넌트 ─────────────
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number;
|
||||
intent?: 'warning' | 'info' | 'critical' | 'muted';
|
||||
}
|
||||
|
||||
function StatCard({ label, value, intent }: StatCardProps) {
|
||||
return (
|
||||
<Card variant="default">
|
||||
<CardContent className="py-3 flex flex-col items-center gap-1">
|
||||
<span className="text-[10px] text-hint">{label}</span>
|
||||
{intent ? (
|
||||
<Badge intent={intent} size="md">
|
||||
{value}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-lg font-bold text-heading">{value}</span>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface DetailRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, mono }: DetailRowProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-hint w-24 shrink-0">{label}</span>
|
||||
<span className={mono ? 'font-mono text-label' : 'text-label'}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default IllegalFishingPattern;
|
||||
@ -1,405 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeftRight, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { Button } from '@shared/components/ui/button';
|
||||
import { Input } from '@shared/components/ui/input';
|
||||
import { Select } from '@shared/components/ui/select';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
|
||||
import { getTransshipSuspects, type VesselAnalysis } from '@/services/analysisApi';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
/**
|
||||
* 환적(Transshipment) 의심 선박 탐지 페이지.
|
||||
*
|
||||
* prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과
|
||||
* (transship_suspect=true) 를 전체 목록으로 조회·집계·상세 확인.
|
||||
*
|
||||
* features.transship_tier (CRITICAL/HIGH/MEDIUM) 와 transship_score 로 심각도 판단.
|
||||
* 기존 `features/vessel/TransferDetection.tsx` 는 선박 상세 수준, 이 페이지는
|
||||
* 전체 목록·통계 수준의 운영 대시보드.
|
||||
*/
|
||||
|
||||
const HOUR_OPTIONS = [1, 6, 12, 24, 48] as const;
|
||||
const LEVEL_OPTIONS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const;
|
||||
const DEFAULT_HOURS = 6;
|
||||
const DEFAULT_SIZE = 200;
|
||||
|
||||
type TransshipTier = 'CRITICAL' | 'HIGH' | 'MEDIUM' | string;
|
||||
|
||||
interface TransshipFeatures {
|
||||
transship_tier?: TransshipTier;
|
||||
transship_score?: number;
|
||||
dark_tier?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function readTier(row: VesselAnalysis): string {
|
||||
const f = (row.features ?? {}) as TransshipFeatures;
|
||||
return f.transship_tier ?? '-';
|
||||
}
|
||||
|
||||
function readScore(row: VesselAnalysis): number | null {
|
||||
const f = (row.features ?? {}) as TransshipFeatures;
|
||||
return typeof f.transship_score === 'number' ? f.transship_score : null;
|
||||
}
|
||||
|
||||
export function TransshipmentDetection() {
|
||||
const { t } = useTranslation('detection');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language) as 'ko' | 'en';
|
||||
|
||||
const [rows, setRows] = useState<VesselAnalysis[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [hours, setHours] = useState<number>(DEFAULT_HOURS);
|
||||
const [levelFilter, setLevelFilter] = useState('');
|
||||
const [mmsiFilter, setMmsiFilter] = useState('');
|
||||
const [selected, setSelected] = useState<VesselAnalysis | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const resp = await getTransshipSuspects({ hours, page: 0, size: DEFAULT_SIZE });
|
||||
setRows(resp.content);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : t('transshipment.error.loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [hours, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
return rows.filter((r) => {
|
||||
if (levelFilter && r.riskLevel !== levelFilter) return false;
|
||||
if (mmsiFilter && !r.mmsi.includes(mmsiFilter)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [rows, levelFilter, mmsiFilter]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const byLevel: Record<string, number> = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
|
||||
const byTier: Record<string, number> = { CRITICAL: 0, HIGH: 0, MEDIUM: 0 };
|
||||
for (const r of rows) {
|
||||
if (r.riskLevel && byLevel[r.riskLevel] !== undefined) {
|
||||
byLevel[r.riskLevel]++;
|
||||
}
|
||||
const tier = readTier(r);
|
||||
if (byTier[tier] !== undefined) {
|
||||
byTier[tier]++;
|
||||
}
|
||||
}
|
||||
return { byLevel, byTier };
|
||||
}, [rows]);
|
||||
|
||||
const cols: DataColumn<VesselAnalysis & Record<string, unknown>>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'analyzedAt',
|
||||
label: t('transshipment.columns.analyzedAt'),
|
||||
width: '140px',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-muted-foreground text-[10px]">{formatDateTime(v as string)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'mmsi',
|
||||
label: t('transshipment.columns.mmsi'),
|
||||
width: '120px',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="font-mono text-[10px] text-cyan-600 dark:text-cyan-400">
|
||||
{v as string}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'transshipPairMmsi',
|
||||
label: t('transshipment.columns.pairMmsi'),
|
||||
width: '120px',
|
||||
render: (v) => (
|
||||
<span className="font-mono text-[10px] text-label">{(v as string) || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'transshipDurationMin',
|
||||
label: t('transshipment.columns.durationMin'),
|
||||
width: '90px',
|
||||
align: 'right',
|
||||
sortable: true,
|
||||
render: (v) => {
|
||||
const n = typeof v === 'number' ? v : Number(v ?? 0);
|
||||
return <span className="font-mono text-label">{n.toFixed(0)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'features',
|
||||
label: t('transshipment.columns.tier'),
|
||||
width: '90px',
|
||||
align: 'center',
|
||||
render: (_, row) => {
|
||||
const tier = readTier(row);
|
||||
const isKnown = ['CRITICAL', 'HIGH', 'MEDIUM'].includes(tier);
|
||||
return (
|
||||
<Badge
|
||||
intent={isKnown ? getAlertLevelIntent(tier) : 'muted'}
|
||||
size="sm"
|
||||
>
|
||||
{isKnown ? getAlertLevelLabel(tier, tc, lang) : tier}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'riskScore',
|
||||
label: t('transshipment.columns.riskScore'),
|
||||
width: '80px',
|
||||
align: 'right',
|
||||
sortable: true,
|
||||
render: (v) => <span className="font-mono text-label">{(v as number) ?? 0}</span>,
|
||||
},
|
||||
{
|
||||
key: 'riskLevel',
|
||||
label: t('transshipment.columns.riskLevel'),
|
||||
width: '90px',
|
||||
align: 'center',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<Badge intent={getAlertLevelIntent(v as string)} size="sm">
|
||||
{getAlertLevelLabel(v as string, tc, lang)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'zoneCode',
|
||||
label: t('transshipment.columns.zone'),
|
||||
width: '130px',
|
||||
render: (v) => <span className="text-hint text-[10px]">{(v as string) || '-'}</span>,
|
||||
},
|
||||
],
|
||||
[t, tc, lang],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={ArrowLeftRight}
|
||||
iconColor="text-purple-600 dark:text-purple-400"
|
||||
title={t('transshipment.title')}
|
||||
description={t('transshipment.desc')}
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{t('transshipment.refresh')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Card variant="default">
|
||||
<CardContent className="text-destructive text-xs py-2">{error}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Section title={t('transshipment.stats.title')}>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
<StatCard label={t('transshipment.stats.total')} value={rows.length} />
|
||||
<StatCard label={t('transshipment.stats.tierCritical')} value={stats.byTier.CRITICAL} intent="critical" />
|
||||
<StatCard label={t('transshipment.stats.tierHigh')} value={stats.byTier.HIGH} intent="warning" />
|
||||
<StatCard label={t('transshipment.stats.tierMedium')} value={stats.byTier.MEDIUM} intent="info" />
|
||||
<StatCard label={t('transshipment.stats.riskCritical')} value={stats.byLevel.CRITICAL} intent="critical" />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t('transshipment.list.title')}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
|
||||
<Select
|
||||
aria-label={t('transshipment.filters.hours')}
|
||||
value={String(hours)}
|
||||
onChange={(e) => setHours(Number(e.target.value))}
|
||||
>
|
||||
{HOUR_OPTIONS.map((h) => (
|
||||
<option key={h} value={h}>
|
||||
{t('transshipment.filters.hoursValue', { h })}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
aria-label={t('transshipment.filters.level')}
|
||||
value={levelFilter}
|
||||
onChange={(e) => setLevelFilter(e.target.value)}
|
||||
>
|
||||
<option value="">{t('transshipment.filters.allLevel')}</option>
|
||||
{LEVEL_OPTIONS.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{getAlertLevelLabel(l, tc, lang)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
aria-label={t('transshipment.filters.mmsi')}
|
||||
placeholder={t('transshipment.filters.mmsi')}
|
||||
value={mmsiFilter}
|
||||
onChange={(e) => setMmsiFilter(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<Badge intent="info" size="sm">
|
||||
{filteredRows.length} / {rows.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredRows.length === 0 && !loading ? (
|
||||
<p className="text-hint text-xs py-4 text-center">
|
||||
{t('transshipment.list.empty', { hours })}
|
||||
</p>
|
||||
) : (
|
||||
<DataTable
|
||||
data={filteredRows as (VesselAnalysis & Record<string, unknown>)[]}
|
||||
columns={cols}
|
||||
pageSize={20}
|
||||
showSearch={false}
|
||||
showExport={false}
|
||||
showPrint={false}
|
||||
onRowClick={(row) => setSelected(row as VesselAnalysis)}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{selected && (
|
||||
<Section title={t('transshipment.detail.title')}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||
<div className="space-y-1.5">
|
||||
<DetailRow
|
||||
label={t('transshipment.columns.analyzedAt')}
|
||||
value={formatDateTime(selected.analyzedAt)}
|
||||
/>
|
||||
<DetailRow label={t('transshipment.columns.mmsi')} value={selected.mmsi} mono />
|
||||
<DetailRow
|
||||
label={t('transshipment.columns.pairMmsi')}
|
||||
value={selected.transshipPairMmsi ?? '-'}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('transshipment.columns.durationMin')}
|
||||
value={`${selected.transshipDurationMin ?? 0}분`}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('transshipment.columns.riskScore')}
|
||||
value={String(selected.riskScore ?? 0)}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('transshipment.columns.zone')}
|
||||
value={selected.zoneCode ?? '-'}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('transshipment.detail.location')}
|
||||
value={
|
||||
selected.lat != null && selected.lon != null
|
||||
? `${selected.lat.toFixed(4)}, ${selected.lon.toFixed(4)}`
|
||||
: '-'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-label">
|
||||
{t('transshipment.columns.tier')}:
|
||||
</span>
|
||||
{(() => {
|
||||
const tier = readTier(selected);
|
||||
const isKnown = ['CRITICAL', 'HIGH', 'MEDIUM'].includes(tier);
|
||||
return (
|
||||
<Badge intent={isKnown ? getAlertLevelIntent(tier) : 'muted'} size="sm">
|
||||
{isKnown ? getAlertLevelLabel(tier, tc, lang) : tier}
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
<span className="text-xs text-label ml-3">
|
||||
{t('transshipment.detail.transshipScore')}:
|
||||
</span>
|
||||
<span className="text-xs font-mono text-heading">
|
||||
{readScore(selected)?.toFixed(1) ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
{selected.features && Object.keys(selected.features).length > 0 && (
|
||||
<div>
|
||||
<div className="text-[10px] text-hint mb-1">
|
||||
{t('transshipment.detail.features')}
|
||||
</div>
|
||||
<pre className="bg-surface-raised text-[10px] text-label p-2 overflow-auto max-h-48">
|
||||
{JSON.stringify(selected.features, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelected(null)}>
|
||||
{t('transshipment.detail.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 내부 컴포넌트 ─────────────
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number;
|
||||
intent?: 'warning' | 'info' | 'critical' | 'muted';
|
||||
}
|
||||
|
||||
function StatCard({ label, value, intent }: StatCardProps) {
|
||||
return (
|
||||
<Card variant="default">
|
||||
<CardContent className="py-3 flex flex-col items-center gap-1">
|
||||
<span className="text-[10px] text-hint text-center">{label}</span>
|
||||
{intent ? (
|
||||
<Badge intent={intent} size="md">
|
||||
{value}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-lg font-bold text-heading">{value}</span>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface DetailRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, mono }: DetailRowProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-hint w-24 shrink-0">{label}</span>
|
||||
<span className={mono ? 'font-mono text-label' : 'text-label'}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TransshipmentDetection;
|
||||
@ -3,5 +3,3 @@ export { GearDetection } from './GearDetection';
|
||||
export { ChinaFishing } from './ChinaFishing';
|
||||
export { GearIdentification } from './GearIdentification';
|
||||
export { GearCollisionDetection } from './GearCollisionDetection';
|
||||
export { IllegalFishingPattern } from './IllegalFishingPattern';
|
||||
export { TransshipmentDetection } from './TransshipmentDetection';
|
||||
|
||||
@ -9,8 +9,6 @@
|
||||
"gearDetection": "Gear Detection",
|
||||
"chinaFishing": "Chinese Vessel",
|
||||
"gearCollision": "Gear Collision",
|
||||
"illegalFishing": "Illegal Fishing",
|
||||
"transshipment": "Transshipment",
|
||||
"patrolRoute": "Patrol Route",
|
||||
"fleetOptimization": "Fleet Optimize",
|
||||
"enforcementHistory": "History",
|
||||
|
||||
@ -15,110 +15,6 @@
|
||||
"title": "Gear Identification",
|
||||
"desc": "SFR-10 | AI-based gear origin & type automatic identification"
|
||||
},
|
||||
"transshipment": {
|
||||
"title": "Transshipment Suspects",
|
||||
"desc": "Vessels passing prediction's 5-stage transshipment filter (cross-type pair → monitoring zone → RENDEZVOUS 90min+ → score 50+ → anti-cluster burst). Severity from features.transship_tier",
|
||||
"refresh": "Refresh",
|
||||
"stats": {
|
||||
"title": "Overview",
|
||||
"total": "Total",
|
||||
"tierCritical": "Transship CRITICAL",
|
||||
"tierHigh": "Transship HIGH",
|
||||
"tierMedium": "Transship MEDIUM",
|
||||
"riskCritical": "Risk CRITICAL"
|
||||
},
|
||||
"list": {
|
||||
"title": "Suspect vessels",
|
||||
"empty": "No transshipment suspects in the last {{hours}} hours."
|
||||
},
|
||||
"columns": {
|
||||
"analyzedAt": "Analyzed",
|
||||
"mmsi": "MMSI",
|
||||
"pairMmsi": "Pair MMSI",
|
||||
"durationMin": "Duration (min)",
|
||||
"tier": "Transship tier",
|
||||
"riskScore": "Risk",
|
||||
"riskLevel": "Risk level",
|
||||
"zone": "Zone"
|
||||
},
|
||||
"filters": {
|
||||
"hours": "Window",
|
||||
"level": "Risk level",
|
||||
"mmsi": "MMSI",
|
||||
"hoursValue": "Last {{h}}h",
|
||||
"allLevel": "All levels"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Transshipment detail",
|
||||
"location": "Location",
|
||||
"features": "Raw features",
|
||||
"transshipScore": "Transship score",
|
||||
"close": "Close"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "Failed to load transshipment suspects."
|
||||
}
|
||||
},
|
||||
"illegalPattern": {
|
||||
"title": "Illegal Fishing Events",
|
||||
"desc": "Integrated view of illegal fishing–related events: zone/gear mismatch, territorial sea intrusion, protected zone entry (READ only — take actions from Event List)",
|
||||
"refresh": "Refresh",
|
||||
"stats": {
|
||||
"title": "Severity distribution",
|
||||
"total": "Total"
|
||||
},
|
||||
"byCategory": {
|
||||
"title": "By category"
|
||||
},
|
||||
"category": {
|
||||
"GEAR_ILLEGAL": "Gear Violation",
|
||||
"EEZ_INTRUSION": "Territorial/Contiguous",
|
||||
"ZONE_DEPARTURE": "Protected Zone Entry"
|
||||
},
|
||||
"categoryDesc": {
|
||||
"GEAR_ILLEGAL": "G-01/G-05/G-06 zone-gear mismatch, fixed-gear drift, pair trawl",
|
||||
"EEZ_INTRUSION": "Territorial sea (CRITICAL) / Contiguous zone high-risk",
|
||||
"ZONE_DEPARTURE": "Protected zone entry with risk ≥ 40"
|
||||
},
|
||||
"list": {
|
||||
"title": "Events",
|
||||
"empty": "No illegal fishing events match the filters."
|
||||
},
|
||||
"columns": {
|
||||
"occurredAt": "Occurred",
|
||||
"level": "Level",
|
||||
"category": "Category",
|
||||
"title": "Title",
|
||||
"mmsi": "MMSI",
|
||||
"zone": "Zone",
|
||||
"status": "Status"
|
||||
},
|
||||
"filters": {
|
||||
"category": "Category",
|
||||
"level": "Level",
|
||||
"mmsi": "MMSI",
|
||||
"limit": "Limit",
|
||||
"allCategory": "All categories",
|
||||
"allLevel": "All levels"
|
||||
},
|
||||
"status": {
|
||||
"NEW": "New",
|
||||
"ACKED": "Acked",
|
||||
"RESOLVED": "Resolved",
|
||||
"FALSE_ALARM": "False alarm"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Event detail",
|
||||
"vesselName": "Vessel name",
|
||||
"location": "Location",
|
||||
"features": "Extra info",
|
||||
"close": "Close",
|
||||
"openEventList": "Open in Event List"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "Failed to load events."
|
||||
}
|
||||
},
|
||||
"gearCollision": {
|
||||
"title": "Gear Identity Collision",
|
||||
"desc": "Same gear name broadcasting from multiple MMSIs in the same cycle — gear duplication / spoofing suspicion",
|
||||
|
||||
@ -9,8 +9,6 @@
|
||||
"gearDetection": "어구 탐지",
|
||||
"chinaFishing": "중국어선 분석",
|
||||
"gearCollision": "어구 정체성 충돌",
|
||||
"illegalFishing": "불법 조업 이벤트",
|
||||
"transshipment": "환적 의심 탐지",
|
||||
"patrolRoute": "순찰경로 추천",
|
||||
"fleetOptimization": "다함정 최적화",
|
||||
"enforcementHistory": "단속 이력",
|
||||
|
||||
@ -15,110 +15,6 @@
|
||||
"title": "어구 식별 분석",
|
||||
"desc": "SFR-10 | AI 기반 어구 원산지·유형 자동 식별 및 판정"
|
||||
},
|
||||
"transshipment": {
|
||||
"title": "환적 의심 탐지",
|
||||
"desc": "prediction 5단계 필터 파이프라인(이종 쌍 → 감시영역 → RENDEZVOUS 90분+ → 점수 50+ → 밀집 방폭) 통과 선박 목록. features.transship_tier 기반 심각도 분류",
|
||||
"refresh": "새로고침",
|
||||
"stats": {
|
||||
"title": "현황 요약",
|
||||
"total": "전체",
|
||||
"tierCritical": "환적 CRITICAL",
|
||||
"tierHigh": "환적 HIGH",
|
||||
"tierMedium": "환적 MEDIUM",
|
||||
"riskCritical": "종합위험 CRITICAL"
|
||||
},
|
||||
"list": {
|
||||
"title": "환적 의심 선박",
|
||||
"empty": "최근 {{hours}}시간 내 환적 의심 선박이 없습니다."
|
||||
},
|
||||
"columns": {
|
||||
"analyzedAt": "분석 시각",
|
||||
"mmsi": "MMSI",
|
||||
"pairMmsi": "상대 MMSI",
|
||||
"durationMin": "지속(분)",
|
||||
"tier": "환적 tier",
|
||||
"riskScore": "위험도",
|
||||
"riskLevel": "종합 위험",
|
||||
"zone": "수역"
|
||||
},
|
||||
"filters": {
|
||||
"hours": "조회 기간",
|
||||
"level": "위험도",
|
||||
"mmsi": "MMSI 검색",
|
||||
"hoursValue": "최근 {{h}}시간",
|
||||
"allLevel": "전체 위험도"
|
||||
},
|
||||
"detail": {
|
||||
"title": "환적 의심 상세",
|
||||
"location": "좌표",
|
||||
"features": "분석 피처 원본",
|
||||
"transshipScore": "환적 점수",
|
||||
"close": "닫기"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "환적 의심 목록을 불러오지 못했습니다."
|
||||
}
|
||||
},
|
||||
"illegalPattern": {
|
||||
"title": "불법 조업 이벤트",
|
||||
"desc": "수역-어구 위반 / 영해 침범 / 특정수역 진입 등 불법 조업 의심 이벤트 통합 조회 (READ 전용 — 처리 액션은 이벤트 목록에서)",
|
||||
"refresh": "새로고침",
|
||||
"stats": {
|
||||
"title": "심각도 분포",
|
||||
"total": "전체"
|
||||
},
|
||||
"byCategory": {
|
||||
"title": "카테고리별 건수"
|
||||
},
|
||||
"category": {
|
||||
"GEAR_ILLEGAL": "어구 위반",
|
||||
"EEZ_INTRUSION": "영해/접속수역 침범",
|
||||
"ZONE_DEPARTURE": "특정수역 진입"
|
||||
},
|
||||
"categoryDesc": {
|
||||
"GEAR_ILLEGAL": "G-01/G-05/G-06 수역·어구 불일치, 고정어구 drift, 쌍끌이 공조",
|
||||
"EEZ_INTRUSION": "영해(CRITICAL) / 접속수역 + 고위험 위반",
|
||||
"ZONE_DEPARTURE": "관심 수역(ZONE_I~IV) 진입 + 위험도 40+"
|
||||
},
|
||||
"list": {
|
||||
"title": "이벤트 목록",
|
||||
"empty": "조건에 맞는 불법 조업 이벤트가 없습니다."
|
||||
},
|
||||
"columns": {
|
||||
"occurredAt": "발생 시각",
|
||||
"level": "심각도",
|
||||
"category": "카테고리",
|
||||
"title": "제목",
|
||||
"mmsi": "MMSI",
|
||||
"zone": "수역",
|
||||
"status": "상태"
|
||||
},
|
||||
"filters": {
|
||||
"category": "카테고리",
|
||||
"level": "심각도",
|
||||
"mmsi": "MMSI 검색",
|
||||
"limit": "최대",
|
||||
"allCategory": "전체 카테고리",
|
||||
"allLevel": "전체 심각도"
|
||||
},
|
||||
"status": {
|
||||
"NEW": "신규",
|
||||
"ACKED": "확인",
|
||||
"RESOLVED": "처리완료",
|
||||
"FALSE_ALARM": "오탐"
|
||||
},
|
||||
"detail": {
|
||||
"title": "이벤트 상세",
|
||||
"vesselName": "선박명",
|
||||
"location": "좌표",
|
||||
"features": "추가 정보",
|
||||
"close": "닫기",
|
||||
"openEventList": "이벤트 목록에서 열기"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "이벤트를 불러오지 못했습니다."
|
||||
}
|
||||
},
|
||||
"gearCollision": {
|
||||
"title": "어구 정체성 충돌 탐지",
|
||||
"desc": "동일 어구 이름이 서로 다른 MMSI 로 같은 사이클에 동시 송출되는 공존 패턴 — 어구 복제/스푸핑 의심",
|
||||
|
||||
@ -1,77 +0,0 @@
|
||||
/**
|
||||
* 불법 조업 이벤트 전용 서비스 — 기존 /api/events 를 category 다중 조회로 래핑.
|
||||
*
|
||||
* category 는 event_generator 의 rule 에서 오는 단일 값이지만, UI 관점에서 "불법 조업"
|
||||
* 은 여러 카테고리의 합집합이다:
|
||||
* - GEAR_ILLEGAL : G-01 수역-어구 / G-05 고정어구 drift / G-06 쌍끌이
|
||||
* - EEZ_INTRUSION : 영해 침범 / 접속수역 + 고위험
|
||||
* - ZONE_DEPARTURE : 특정수역 진입 (관심구역 모니터링)
|
||||
*
|
||||
* backend 변경 없이 클라이언트에서 병렬 조회 후 머지한다.
|
||||
*/
|
||||
import { getEvents, type EventPageResponse, type PredictionEvent } from './event';
|
||||
|
||||
export const ILLEGAL_FISHING_CATEGORIES = [
|
||||
'GEAR_ILLEGAL',
|
||||
'EEZ_INTRUSION',
|
||||
'ZONE_DEPARTURE',
|
||||
] as const;
|
||||
|
||||
export type IllegalFishingCategory = (typeof ILLEGAL_FISHING_CATEGORIES)[number];
|
||||
|
||||
export interface ListParams {
|
||||
/** 단일 카테고리를 지정하면 해당 카테고리만, '' 이면 3개 모두 병합 조회 */
|
||||
category?: IllegalFishingCategory | '';
|
||||
level?: string;
|
||||
status?: string;
|
||||
vesselMmsi?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface IllegalFishingPatternPage {
|
||||
content: PredictionEvent[];
|
||||
totalElements: number;
|
||||
byCategory: Record<string, number>;
|
||||
byLevel: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 병합 조회 — category 미지정 시 3개 병렬 조회 후 occurredAt desc 정렬로 머지.
|
||||
* 각 카테고리 최대 size 건씩 수집하므로, 기본 200 * 3 = 600 건이 상한.
|
||||
*/
|
||||
export async function listIllegalFishingEvents(params?: ListParams): Promise<IllegalFishingPatternPage> {
|
||||
const size = params?.size ?? 200;
|
||||
const targetCategories: IllegalFishingCategory[] = params?.category
|
||||
? [params.category]
|
||||
: [...ILLEGAL_FISHING_CATEGORIES];
|
||||
|
||||
const pages: EventPageResponse[] = await Promise.all(
|
||||
targetCategories.map((category) =>
|
||||
getEvents({
|
||||
category,
|
||||
level: params?.level,
|
||||
status: params?.status,
|
||||
vesselMmsi: params?.vesselMmsi,
|
||||
page: 0,
|
||||
size,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const allEvents: PredictionEvent[] = pages.flatMap((p) => p.content);
|
||||
allEvents.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
|
||||
|
||||
const byCategory: Record<string, number> = {};
|
||||
const byLevel: Record<string, number> = {};
|
||||
for (const e of allEvents) {
|
||||
byCategory[e.category] = (byCategory[e.category] ?? 0) + 1;
|
||||
byLevel[e.level] = (byLevel[e.level] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
content: allEvents,
|
||||
totalElements: pages.reduce((acc, p) => acc + p.totalElements, 0),
|
||||
byCategory,
|
||||
byLevel,
|
||||
};
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
"""사이클 스테이지 에러 경계 유틸.
|
||||
|
||||
`run_analysis_cycle` 내부의 각 스테이지를 한 지점에서 감싸서
|
||||
실패 스테이지를 명시적으로 로깅하고, 부분 실패가 후속 스테이지를
|
||||
막지 않도록 한다.
|
||||
|
||||
설계 원칙:
|
||||
- 비필수 스테이지는 예외를 흡수하고 None 을 반환 → 호출자는
|
||||
`if result is None` 로 건너뛰기 선택 가능
|
||||
- 필수 스테이지(`required=True`)는 예외를 그대로 올려 상위
|
||||
`run_analysis_cycle` 의 top-level try/except 가 잡도록 한다
|
||||
- `logger.exception` 사용으로 stacktrace 가 저널에 남도록 하여
|
||||
원격 서버(journalctl) 에서 실패 지점 특정 가능
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
def run_stage(
|
||||
name: str,
|
||||
fn: Callable[..., T],
|
||||
*args: Any,
|
||||
required: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> T | None:
|
||||
"""스테이지 실행 + 지속시간 로깅 + 실패 격리.
|
||||
|
||||
Args:
|
||||
name: 스테이지 이름 (로그 라벨). 'fleet_tracking', 'pair_detection' 등
|
||||
fn: 실행할 호출 가능 객체
|
||||
*args, **kwargs: fn 에 전달
|
||||
required: True 면 실패 시 예외를 re-raise. False 면 None 반환하고 계속
|
||||
|
||||
Returns:
|
||||
fn 의 반환값, 또는 실패 시 None (required=False 일 때)
|
||||
|
||||
Raises:
|
||||
fn 이 던진 예외 (required=True 일 때만)
|
||||
"""
|
||||
t0 = time.time()
|
||||
try:
|
||||
result = fn(*args, **kwargs)
|
||||
elapsed = time.time() - t0
|
||||
logger.info('stage %s ok in %.2fs', name, elapsed)
|
||||
return result
|
||||
except Exception as e:
|
||||
elapsed = time.time() - t0
|
||||
logger.exception('stage %s failed after %.2fs: %s', name, elapsed, e)
|
||||
if required:
|
||||
raise
|
||||
return None
|
||||
@ -8,7 +8,6 @@ from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from config import settings
|
||||
from fleet_tracker import GEAR_PATTERN
|
||||
from pipeline.stage_runner import run_stage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -70,7 +69,7 @@ def _fetch_dark_history(kcg_conn, mmsi_list: list[str]) -> dict[str, dict]:
|
||||
for m, n7, n24, t in cur.fetchall()
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception('fetch_dark_history failed: %s', e)
|
||||
logger.warning('fetch_dark_history failed: %s', e)
|
||||
return {}
|
||||
|
||||
|
||||
@ -171,7 +170,7 @@ def run_analysis_cycle():
|
||||
collision_events['skipped_low'],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('gear collision event promotion failed: %s', e)
|
||||
logger.warning('gear collision event promotion failed: %s', e)
|
||||
|
||||
fleet_roles = fleet_tracker.build_fleet_clusters(vessel_dfs)
|
||||
|
||||
@ -194,7 +193,7 @@ def run_analysis_cycle():
|
||||
logger.info('group polygons: %d saved, %d cleaned, %d gear groups',
|
||||
saved, cleaned, len(gear_groups))
|
||||
except Exception as e:
|
||||
logger.exception('group polygon generation failed: %s', e)
|
||||
logger.warning('group polygon generation failed: %s', e)
|
||||
|
||||
# 4.7 어구 연관성 분석 (멀티모델 패턴 추적)
|
||||
try:
|
||||
@ -227,7 +226,7 @@ def run_analysis_cycle():
|
||||
inference_result['skipped'],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('gear correlation failed: %s', e)
|
||||
logger.warning('gear correlation failed: %s', e)
|
||||
|
||||
# 4.9 페어 후보 탐색 (bbox 1차 + 궤적 유사도 2차 → G-06 pair_trawl 판정)
|
||||
pair_results: dict[str, dict] = {}
|
||||
@ -301,7 +300,7 @@ def run_analysis_cycle():
|
||||
REJECT_COUNTERS['insufficient_aligned'], REJECT_COUNTERS['no_sync_at_any_tier'],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('pair detection failed: %s', e)
|
||||
logger.warning('pair detection failed: %s', e)
|
||||
|
||||
# 5. 선박별 추가 알고리즘 → AnalysisResult 생성
|
||||
# dark 이력 일괄 조회 (7일 history) — 사이클당 1회
|
||||
@ -713,36 +712,37 @@ def run_analysis_cycle():
|
||||
'transship_score': item['score'],
|
||||
}
|
||||
|
||||
# 7. 결과 저장 (필수 — 실패 시 사이클 abort)
|
||||
upserted = run_stage('upsert_results', kcgdb.upsert_results, results, required=True)
|
||||
run_stage('cleanup_old', kcgdb.cleanup_old, hours=48)
|
||||
# 7. 결과 저장
|
||||
upserted = kcgdb.upsert_results(results)
|
||||
kcgdb.cleanup_old(hours=48)
|
||||
|
||||
# 8. 출력 모듈 — 각 단계를 독립적으로 실행해 실패 지점을 명시적으로 기록.
|
||||
# 한 모듈이 깨져도 다른 모듈은 계속 돌아가야 한다 (예: event_generator 는 실패했어도
|
||||
# kpi_writer / stats_aggregator / alert_dispatcher 는 이전 사이클 결과로 동작 가능).
|
||||
from output.violation_classifier import run_violation_classifier
|
||||
from output.event_generator import run_event_generator
|
||||
from output.kpi_writer import run_kpi_writer
|
||||
from output.stats_aggregator import aggregate_hourly, aggregate_daily
|
||||
from output.alert_dispatcher import run_alert_dispatcher
|
||||
# 8. 출력 모듈 (이벤트 생성, 위반 분류, KPI 갱신, 통계 집계, 경보)
|
||||
try:
|
||||
from output.violation_classifier import run_violation_classifier
|
||||
from output.event_generator import run_event_generator
|
||||
from output.kpi_writer import run_kpi_writer
|
||||
from output.stats_aggregator import aggregate_hourly, aggregate_daily
|
||||
from output.alert_dispatcher import run_alert_dispatcher
|
||||
|
||||
from dataclasses import asdict
|
||||
results_dicts = [asdict(r) for r in results]
|
||||
# 필드명 매핑 (AnalysisResult → 출력 모듈 기대 형식)
|
||||
for d in results_dicts:
|
||||
d['zone_code'] = d.pop('zone', None)
|
||||
d['gap_duration_min'] = d.get('gap_duration_min', 0)
|
||||
d['transship_suspect'] = d.pop('is_transship_suspect', False)
|
||||
d['fleet_is_leader'] = d.pop('is_leader', False)
|
||||
d['fleet_cluster_id'] = d.pop('cluster_id', None)
|
||||
d['speed_kn'] = None # 분석 결과에 속도 없음
|
||||
|
||||
run_stage('violation_classifier', run_violation_classifier, results_dicts)
|
||||
run_stage('event_generator', run_event_generator, results_dicts)
|
||||
run_stage('kpi_writer', run_kpi_writer)
|
||||
run_stage('stats_aggregate_hourly', aggregate_hourly)
|
||||
run_stage('stats_aggregate_daily', aggregate_daily)
|
||||
run_stage('alert_dispatcher', run_alert_dispatcher)
|
||||
from dataclasses import asdict
|
||||
results_dicts = [asdict(r) for r in results]
|
||||
# 필드명 매핑 (AnalysisResult → 출력 모듈 기대 형식)
|
||||
for d in results_dicts:
|
||||
d['zone_code'] = d.pop('zone', None)
|
||||
d['gap_duration_min'] = d.get('gap_duration_min', 0)
|
||||
d['transship_suspect'] = d.pop('is_transship_suspect', False)
|
||||
d['fleet_is_leader'] = d.pop('is_leader', False)
|
||||
d['fleet_cluster_id'] = d.pop('cluster_id', None)
|
||||
d['speed_kn'] = None # 분석 결과에 속도 없음
|
||||
run_violation_classifier(results_dicts)
|
||||
run_event_generator(results_dicts)
|
||||
run_kpi_writer()
|
||||
aggregate_hourly()
|
||||
aggregate_daily()
|
||||
run_alert_dispatcher()
|
||||
logger.info('output modules completed')
|
||||
except Exception as e:
|
||||
logger.warning('output modules failed (non-fatal): %s', e)
|
||||
|
||||
# 9. Redis에 분석 컨텍스트 캐싱 (채팅용)
|
||||
try:
|
||||
@ -788,7 +788,7 @@ def run_analysis_cycle():
|
||||
'polygon_summary': kcgdb.fetch_polygon_summary(),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception('failed to cache analysis context for chat: %s', e)
|
||||
logger.warning('failed to cache analysis context for chat: %s', e)
|
||||
|
||||
elapsed = round(time.time() - start, 2)
|
||||
_last_run['duration_sec'] = elapsed
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user