feat(detection): GEAR_IDENTITY_COLLISION 탐지 패턴 추가

동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클에 동시 AIS 송출되는
공존 케이스를 신규 탐지 패턴으로 분리해 기록·분류한다. 부수 효과로
fleet_tracker.track_gear_identity 의 PK 충돌로 인한 사이클 실패도 해소.

Prediction
- algorithms/gear_identity.py: detect_gear_name_collisions + classify_severity
- fleet_tracker.py: 공존/교체 분기 분리, UPSERT helper, savepoint 점수 이전
- output/event_generator.py: run_gear_identity_collision_events 추가
- scheduler.py: track_gear_identity 직후 이벤트 승격 호출

Backend (domain/analysis)
- GearIdentityCollision 엔티티 + Repository(Specification+stats)
- GearIdentityCollisionService (@Transactional readOnly / @Auditable resolve)
- GearCollisionController /api/analysis/gear-collisions (list/stats/detail/resolve)
- GearCollisionResponse / StatsResponse / ResolveRequest (record)

DB
- V030__gear_identity_collision.sql: gear_identity_collisions 테이블
  + auth_perm_tree 엔트리(detection:gear-collision nav_sort=950) + 역할별 권한

Frontend
- shared/constants/gearCollisionStatuses.ts + catalogRegistry 등록
- services/gearCollisionApi.ts (list/stats/get/resolve)
- features/detection/GearCollisionDetection.tsx (PageContainer+Section+DataTable
  + 분류 액션 폼, design system SSOT 준수)
- componentRegistry + feature index + i18n detection.json / common.json(ko/en)
This commit is contained in:
htlee 2026-04-17 06:53:12 +09:00
부모 831045ace9
커밋 a4e29629fc
22개의 변경된 파일1804개의 추가작업 그리고 65개의 파일을 삭제

파일 보기

@ -0,0 +1,64 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.permission.annotation.RequirePermission;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.*;
/**
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 조회 + 분류 API.
*
* 경로: /api/analysis/gear-collisions
* - GET / 목록 (status/severity/name 필터, hours 윈도우)
* - GET /stats status/severity 집계
* - GET /{id} 단건 상세
* - POST /{id}/resolve 분류 (REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE / REOPEN)
*/
@RestController
@RequestMapping("/api/analysis/gear-collisions")
@RequiredArgsConstructor
public class GearCollisionController {
private static final String RESOURCE = "detection:gear-collision";
private final GearIdentityCollisionService service;
@GetMapping
@RequirePermission(resource = RESOURCE, operation = "READ")
public Page<GearCollisionResponse> list(
@RequestParam(required = false) String status,
@RequestParam(required = false) String severity,
@RequestParam(required = false) String name,
@RequestParam(defaultValue = "48") int hours,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return service.list(status, severity, name, hours,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "lastSeenAt")))
.map(GearCollisionResponse::from);
}
@GetMapping("/stats")
@RequirePermission(resource = RESOURCE, operation = "READ")
public GearCollisionStatsResponse stats(@RequestParam(defaultValue = "48") int hours) {
return service.stats(hours);
}
@GetMapping("/{id}")
@RequirePermission(resource = RESOURCE, operation = "READ")
public GearCollisionResponse get(@PathVariable Long id) {
return GearCollisionResponse.from(service.get(id));
}
@PostMapping("/{id}/resolve")
@RequirePermission(resource = RESOURCE, operation = "UPDATE")
public GearCollisionResponse resolve(
@PathVariable Long id,
@Valid @RequestBody GearCollisionResolveRequest req
) {
return GearCollisionResponse.from(service.resolve(id, req));
}
}

파일 보기

@ -0,0 +1,18 @@
package gc.mda.kcg.domain.analysis;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
/**
* gear_identity_collisions 분류(해결) 액션 요청.
*
* action: REVIEWED | CONFIRMED_ILLEGAL | FALSE_POSITIVE | REOPEN
* note : 선택 (운영자 메모)
*/
public record GearCollisionResolveRequest(
@NotBlank
@Pattern(regexp = "REVIEWED|CONFIRMED_ILLEGAL|FALSE_POSITIVE|REOPEN")
String action,
String note
) {
}

파일 보기

@ -0,0 +1,57 @@
package gc.mda.kcg.domain.analysis;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
/**
* gear_identity_collisions 조회 응답 DTO.
*/
public record GearCollisionResponse(
Long id,
String name,
String mmsiLo,
String mmsiHi,
String parentName,
Long parentVesselId,
OffsetDateTime firstSeenAt,
OffsetDateTime lastSeenAt,
Integer coexistenceCount,
Integer swapCount,
BigDecimal maxDistanceKm,
BigDecimal lastLatLo,
BigDecimal lastLonLo,
BigDecimal lastLatHi,
BigDecimal lastLonHi,
String severity,
String status,
String resolutionNote,
List<Map<String, Object>> evidence,
OffsetDateTime updatedAt
) {
public static GearCollisionResponse from(GearIdentityCollision e) {
return new GearCollisionResponse(
e.getId(),
e.getName(),
e.getMmsiLo(),
e.getMmsiHi(),
e.getParentName(),
e.getParentVesselId(),
e.getFirstSeenAt(),
e.getLastSeenAt(),
e.getCoexistenceCount(),
e.getSwapCount(),
e.getMaxDistanceKm(),
e.getLastLatLo(),
e.getLastLonLo(),
e.getLastLatHi(),
e.getLastLonHi(),
e.getSeverity(),
e.getStatus(),
e.getResolutionNote(),
e.getEvidence(),
e.getUpdatedAt()
);
}
}

파일 보기

@ -0,0 +1,14 @@
package gc.mda.kcg.domain.analysis;
import java.util.Map;
/**
* gear_identity_collisions status/severity 집계 응답.
*/
public record GearCollisionStatsResponse(
long total,
Map<String, Long> byStatus,
Map<String, Long> bySeverity,
int hours
) {
}

파일 보기

@ -0,0 +1,99 @@
package gc.mda.kcg.domain.analysis;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* gear_identity_collisions 엔티티 (GEAR_IDENTITY_COLLISION 탐지 패턴).
*
* 동일 어구 이름이 서로 다른 MMSI 같은 cycle 동시 송출되는 공존 이력.
* prediction 엔진이 5분 주기로 UPSERT, 백엔드는 조회 운영자 분류(status) 갱신.
*/
@Entity
@Table(name = "gear_identity_collisions", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class GearIdentityCollision {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false, length = 200)
private String name;
@Column(name = "mmsi_lo", nullable = false, length = 20)
private String mmsiLo;
@Column(name = "mmsi_hi", nullable = false, length = 20)
private String mmsiHi;
@Column(name = "parent_name", length = 100)
private String parentName;
@Column(name = "parent_vessel_id")
private Long parentVesselId;
@Column(name = "first_seen_at", nullable = false)
private OffsetDateTime firstSeenAt;
@Column(name = "last_seen_at", nullable = false)
private OffsetDateTime lastSeenAt;
@Column(name = "coexistence_count", nullable = false)
private Integer coexistenceCount;
@Column(name = "swap_count", nullable = false)
private Integer swapCount;
@Column(name = "max_distance_km", precision = 8, scale = 2)
private BigDecimal maxDistanceKm;
@Column(name = "last_lat_lo", precision = 9, scale = 6)
private BigDecimal lastLatLo;
@Column(name = "last_lon_lo", precision = 10, scale = 6)
private BigDecimal lastLonLo;
@Column(name = "last_lat_hi", precision = 9, scale = 6)
private BigDecimal lastLatHi;
@Column(name = "last_lon_hi", precision = 10, scale = 6)
private BigDecimal lastLonHi;
@Column(name = "severity", nullable = false, length = 20)
private String severity;
@Column(name = "status", nullable = false, length = 30)
private String status;
@Column(name = "resolved_by")
private UUID resolvedBy;
@Column(name = "resolved_at")
private OffsetDateTime resolvedAt;
@Column(name = "resolution_note", columnDefinition = "text")
private String resolutionNote;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "evidence", columnDefinition = "jsonb")
private List<Map<String, Object>> evidence;
@Column(name = "created_at", nullable = false, updatable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
}

파일 보기

@ -0,0 +1,41 @@
package gc.mda.kcg.domain.analysis;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import java.time.OffsetDateTime;
import java.util.List;
public interface GearIdentityCollisionRepository
extends JpaRepository<GearIdentityCollision, Long>,
JpaSpecificationExecutor<GearIdentityCollision> {
Page<GearIdentityCollision> findAllByLastSeenAtAfterOrderByLastSeenAtDesc(
OffsetDateTime after, Pageable pageable);
/**
* status 카운트 집계 (hours 윈도우).
* 반환: [{status, count}, ...] Object[] {String status, Long count}
*/
@Query("""
SELECT g.status AS status, COUNT(g) AS cnt
FROM GearIdentityCollision g
WHERE g.lastSeenAt > :after
GROUP BY g.status
""")
List<Object[]> countByStatus(OffsetDateTime after);
/**
* severity 카운트 집계 (hours 윈도우).
*/
@Query("""
SELECT g.severity AS severity, COUNT(g) AS cnt
FROM GearIdentityCollision g
WHERE g.lastSeenAt > :after
GROUP BY g.severity
""")
List<Object[]> countBySeverity(OffsetDateTime after);
}

파일 보기

@ -0,0 +1,133 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.audit.annotation.Auditable;
import gc.mda.kcg.auth.AuthPrincipal;
import jakarta.persistence.EntityNotFoundException;
import jakarta.persistence.criteria.Predicate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 조회/분류 서비스.
*
* 조회는 모두 {@link Transactional}(readOnly=true), 분류 액션은 {@link Auditable}
* 감사로그 기록. 상태 전이 화이트리스트는 REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE / REOPEN.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GearIdentityCollisionService {
private static final String RESOURCE_TYPE = "GEAR_COLLISION";
private final GearIdentityCollisionRepository repository;
@Transactional(readOnly = true)
public Page<GearIdentityCollision> list(
String status,
String severity,
String name,
int hours,
Pageable pageable
) {
OffsetDateTime after = OffsetDateTime.now().minusHours(Math.max(hours, 1));
Specification<GearIdentityCollision> spec = (root, query, cb) -> {
List<Predicate> preds = new ArrayList<>();
preds.add(cb.greaterThan(root.get("lastSeenAt"), after));
if (status != null && !status.isBlank()) {
preds.add(cb.equal(root.get("status"), status));
}
if (severity != null && !severity.isBlank()) {
preds.add(cb.equal(root.get("severity"), severity));
}
if (name != null && !name.isBlank()) {
preds.add(cb.like(root.get("name"), "%" + name + "%"));
}
return cb.and(preds.toArray(new Predicate[0]));
};
return repository.findAll(spec, pageable);
}
@Transactional(readOnly = true)
public GearIdentityCollision get(Long id) {
return repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("GEAR_COLLISION_NOT_FOUND: " + id));
}
@Transactional(readOnly = true)
public GearCollisionStatsResponse stats(int hours) {
OffsetDateTime after = OffsetDateTime.now().minusHours(Math.max(hours, 1));
Map<String, Long> byStatus = new HashMap<>();
long total = 0;
for (Object[] row : repository.countByStatus(after)) {
String s = (String) row[0];
long c = ((Number) row[1]).longValue();
byStatus.put(s, c);
total += c;
}
Map<String, Long> bySeverity = new HashMap<>();
for (Object[] row : repository.countBySeverity(after)) {
bySeverity.put((String) row[0], ((Number) row[1]).longValue());
}
return new GearCollisionStatsResponse(total, byStatus, bySeverity, hours);
}
@Auditable(action = "GEAR_COLLISION_RESOLVE", resourceType = RESOURCE_TYPE)
@Transactional
public GearIdentityCollision resolve(Long id, GearCollisionResolveRequest req) {
GearIdentityCollision row = repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("GEAR_COLLISION_NOT_FOUND: " + id));
AuthPrincipal principal = currentPrincipal();
OffsetDateTime now = OffsetDateTime.now();
switch (req.action().toUpperCase()) {
case "REVIEWED" -> {
row.setStatus("REVIEWED");
row.setResolvedBy(principal != null ? principal.getUserId() : null);
row.setResolvedAt(now);
row.setResolutionNote(req.note());
}
case "CONFIRMED_ILLEGAL" -> {
row.setStatus("CONFIRMED_ILLEGAL");
row.setResolvedBy(principal != null ? principal.getUserId() : null);
row.setResolvedAt(now);
row.setResolutionNote(req.note());
}
case "FALSE_POSITIVE" -> {
row.setStatus("FALSE_POSITIVE");
row.setResolvedBy(principal != null ? principal.getUserId() : null);
row.setResolvedAt(now);
row.setResolutionNote(req.note());
}
case "REOPEN" -> {
row.setStatus("OPEN");
row.setResolvedBy(null);
row.setResolvedAt(null);
row.setResolutionNote(req.note());
}
default -> throw new IllegalArgumentException("UNKNOWN_ACTION: " + req.action());
}
row.setUpdatedAt(now);
return repository.save(row);
}
private AuthPrincipal currentPrincipal() {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) {
return p;
}
return null;
}
}

파일 보기

@ -0,0 +1,90 @@
-- ============================================================================
-- V030: 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 패턴
-- 동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클 내 동시 AIS 송출되는
-- 케이스를 독립 탐지 패턴으로 기록. 공존 이력·심각도·운영자 분류 상태를 보존한다.
-- ============================================================================
-- ──────────────────────────────────────────────────────────────────
-- 1. 충돌 이력 테이블
-- ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS kcg.gear_identity_collisions (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL, -- 동일한 어구 이름
mmsi_lo VARCHAR(20) NOT NULL, -- 정렬된 쌍 (lo < hi)
mmsi_hi VARCHAR(20) NOT NULL,
parent_name VARCHAR(100),
parent_vessel_id BIGINT, -- fleet_vessels.id
first_seen_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
coexistence_count INT NOT NULL DEFAULT 1, -- 동일 cycle 공존 누적
swap_count INT NOT NULL DEFAULT 0, -- 시간차 스왑 누적(참고)
max_distance_km NUMERIC(8,2), -- 양 위치 최대 거리
last_lat_lo NUMERIC(9,6),
last_lon_lo NUMERIC(10,6),
last_lat_hi NUMERIC(9,6),
last_lon_hi NUMERIC(10,6),
severity VARCHAR(20) NOT NULL DEFAULT 'MEDIUM', -- CRITICAL/HIGH/MEDIUM/LOW
status VARCHAR(30) NOT NULL DEFAULT 'OPEN', -- OPEN/REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE
resolved_by UUID,
resolved_at TIMESTAMPTZ,
resolution_note TEXT,
evidence JSONB, -- 최근 관측 요약
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT gear_identity_collisions_pair_uk UNIQUE (name, mmsi_lo, mmsi_hi),
CONSTRAINT gear_identity_collisions_pair_ord CHECK (mmsi_lo < mmsi_hi)
);
CREATE INDEX IF NOT EXISTS idx_gic_status
ON kcg.gear_identity_collisions(status, last_seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_gic_severity
ON kcg.gear_identity_collisions(severity, last_seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_gic_parent
ON kcg.gear_identity_collisions(parent_vessel_id)
WHERE parent_vessel_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_gic_name
ON kcg.gear_identity_collisions(name);
CREATE INDEX IF NOT EXISTS idx_gic_last_seen
ON kcg.gear_identity_collisions(last_seen_at DESC);
COMMENT ON TABLE kcg.gear_identity_collisions IS
'동일 어구 이름이 서로 다른 MMSI 로 공존 송출되는 스푸핑 의심 패턴 (GEAR_IDENTITY_COLLISION).';
-- ──────────────────────────────────────────────────────────────────
-- 2. 권한 트리 / 메뉴 슬롯 (V024 이후 detection 그룹은 평탄화됨: parent_cd=NULL)
-- ──────────────────────────────────────────────────────────────────
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:gear-collision', NULL, '어구 정체성 충돌', 1, 40,
'/gear-collision', 'nav.gearCollision',
'features/detection/GearCollisionDetection', 950,
'{"ko":"어구 정체성 충돌","en":"Gear Identity Collision"}'::jsonb)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────────
-- 3. 권한 부여
-- ADMIN : 전체 op (READ/CREATE/UPDATE/DELETE/EXPORT)
-- OPERATOR: READ + UPDATE (분류 액션)
-- VIEWER/ANALYST/FIELD: READ
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:gear-collision', 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:gear-collision', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('UPDATE')) AS op(oper_cd)
WHERE r.role_cd = 'OPERATOR'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:gear-collision', 'READ', 'Y'
FROM kcg.auth_role r
WHERE r.role_cd IN ('VIEWER', 'ANALYST', 'FIELD')
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -39,6 +39,9 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
'features/detection/ChinaFishing': lazy(() => 'features/detection/ChinaFishing': lazy(() =>
import('@features/detection').then((m) => ({ default: m.ChinaFishing })), import('@features/detection').then((m) => ({ default: m.ChinaFishing })),
), ),
'features/detection/GearCollisionDetection': lazy(() =>
import('@features/detection').then((m) => ({ default: m.GearCollisionDetection })),
),
// ── 단속·이벤트 ── // ── 단속·이벤트 ──
'features/enforcement/EnforcementHistory': lazy(() => 'features/enforcement/EnforcementHistory': lazy(() =>
import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })), import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })),

파일 보기

@ -0,0 +1,427 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertOctagon, 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 { Textarea } from '@shared/components/ui/textarea';
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 {
GEAR_COLLISION_STATUS_ORDER,
getGearCollisionStatusIntent,
getGearCollisionStatusLabel,
} from '@shared/constants/gearCollisionStatuses';
import {
getGearCollisionStats,
listGearCollisions,
resolveGearCollision,
type GearCollision,
type GearCollisionResolveAction,
type GearCollisionStats,
} from '@/services/gearCollisionApi';
import { useSettingsStore } from '@stores/settingsStore';
/**
* (GEAR_IDENTITY_COLLISION) .
*
* MMSI cycle
* REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE .
*/
type SeverityCode = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
const SEVERITY_OPTIONS: SeverityCode[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
const DEFAULT_HOURS = 48;
export function GearCollisionDetection() {
const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language) as 'ko' | 'en';
const [rows, setRows] = useState<GearCollision[]>([]);
const [stats, setStats] = useState<GearCollisionStats | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [severityFilter, setSeverityFilter] = useState<string>('');
const [nameFilter, setNameFilter] = useState<string>('');
const [selected, setSelected] = useState<GearCollision | null>(null);
const [resolveAction, setResolveAction] = useState<GearCollisionResolveAction>('REVIEWED');
const [resolveNote, setResolveNote] = useState('');
const [resolving, setResolving] = useState(false);
const loadData = useCallback(async () => {
setLoading(true);
setError('');
try {
const [page, summary] = await Promise.all([
listGearCollisions({
status: statusFilter || undefined,
severity: severityFilter || undefined,
name: nameFilter || undefined,
hours: DEFAULT_HOURS,
size: 200,
}),
getGearCollisionStats(DEFAULT_HOURS),
]);
setRows(page.content);
setStats(summary);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : t('gearCollision.error.loadFailed'));
} finally {
setLoading(false);
}
}, [statusFilter, severityFilter, nameFilter, t]);
useEffect(() => { loadData(); }, [loadData]);
// 선택된 row 와 현재 목록의 동기화
const syncedSelected = useMemo(
() => selected ? rows.find((r) => r.id === selected.id) ?? selected : null,
[rows, selected],
);
const cols: DataColumn<GearCollision & Record<string, unknown>>[] = useMemo(() => [
{
key: 'name', label: t('gearCollision.columns.name'), minWidth: '120px', sortable: true,
render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-medium">{v as string}</span>,
},
{
key: 'mmsiLo', label: t('gearCollision.columns.mmsiPair'), minWidth: '160px',
render: (_, row) => (
<span className="font-mono text-[10px] text-label">
{row.mmsiLo} {row.mmsiHi}
</span>
),
},
{
key: 'parentName', label: t('gearCollision.columns.parentName'), minWidth: '110px',
render: (v) => <span className="text-hint text-[10px]">{(v as string) || '-'}</span>,
},
{
key: 'coexistenceCount', label: t('gearCollision.columns.coexistenceCount'),
width: '90px', align: 'center', sortable: true,
render: (v) => <span className="font-mono text-label">{v as number}</span>,
},
{
key: 'maxDistanceKm', label: t('gearCollision.columns.maxDistance'),
width: '110px', align: 'right', sortable: true,
render: (v) => {
const n = typeof v === 'number' ? v : Number(v ?? 0);
return <span className="font-mono text-[10px] text-label">{n.toFixed(2)}</span>;
},
},
{
key: 'severity', label: t('gearCollision.columns.severity'),
width: '90px', align: 'center', sortable: true,
render: (v) => (
<Badge intent={getAlertLevelIntent(v as string)} size="sm">
{getAlertLevelLabel(v as string, tc, lang)}
</Badge>
),
},
{
key: 'status', label: t('gearCollision.columns.status'),
width: '110px', align: 'center', sortable: true,
render: (v) => (
<Badge intent={getGearCollisionStatusIntent(v as string)} size="sm">
{getGearCollisionStatusLabel(v as string, t, lang)}
</Badge>
),
},
{
key: 'lastSeenAt', label: t('gearCollision.columns.lastSeen'),
width: '130px', sortable: true,
render: (v) => (
<span className="text-muted-foreground text-[10px]">{formatDateTime(v as string)}</span>
),
},
], [t, tc, lang]);
const handleResolve = useCallback(async () => {
if (!syncedSelected) return;
const ok = window.confirm(t('gearCollision.resolve.confirmPrompt'));
if (!ok) return;
setResolving(true);
try {
const updated = await resolveGearCollision(syncedSelected.id, {
action: resolveAction,
note: resolveNote || undefined,
});
setSelected(updated);
setResolveNote('');
await loadData();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : t('gearCollision.error.resolveFailed'));
} finally {
setResolving(false);
}
}, [syncedSelected, resolveAction, resolveNote, loadData, t]);
const statusCount = (code: string) => stats?.byStatus?.[code] ?? 0;
return (
<PageContainer>
<PageHeader
icon={AlertOctagon}
iconColor="text-orange-600 dark:text-orange-400"
title={t('gearCollision.title')}
description={t('gearCollision.desc')}
actions={
<Button
variant="outline"
size="sm"
onClick={loadData}
disabled={loading}
icon={<RefreshCw className="w-3.5 h-3.5" />}
>
{t('gearCollision.list.refresh')}
</Button>
}
/>
{error && (
<Card variant="default">
<CardContent className="text-destructive text-xs py-2">{error}</CardContent>
</Card>
)}
<Section title={t('gearCollision.stats.title')}>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
<StatCard label={t('gearCollision.stats.total')} value={stats?.total ?? 0} />
<StatCard
label={t('gearCollision.stats.open')}
value={statusCount('OPEN')}
intent="warning"
/>
<StatCard
label={t('gearCollision.stats.reviewed')}
value={statusCount('REVIEWED')}
intent="info"
/>
<StatCard
label={t('gearCollision.stats.confirmed')}
value={statusCount('CONFIRMED_ILLEGAL')}
intent="critical"
/>
<StatCard
label={t('gearCollision.stats.falsePositive')}
value={statusCount('FALSE_POSITIVE')}
intent="muted"
/>
</div>
</Section>
<Section title={t('gearCollision.list.title')}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
<Select
aria-label={t('gearCollision.filters.status')}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="">{t('gearCollision.filters.allStatus')}</option>
{GEAR_COLLISION_STATUS_ORDER.map((s) => (
<option key={s} value={s}>{getGearCollisionStatusLabel(s, t, lang)}</option>
))}
</Select>
<Select
aria-label={t('gearCollision.filters.severity')}
value={severityFilter}
onChange={(e) => setSeverityFilter(e.target.value)}
>
<option value="">{t('gearCollision.filters.allSeverity')}</option>
{SEVERITY_OPTIONS.map((sv) => (
<option key={sv} value={sv}>{getAlertLevelLabel(sv, tc, lang)}</option>
))}
</Select>
<Input
aria-label={t('gearCollision.filters.name')}
placeholder={t('gearCollision.filters.name')}
value={nameFilter}
onChange={(e) => setNameFilter(e.target.value)}
/>
<div className="flex items-center justify-end">
<Badge intent="info" size="sm">
{t('gearCollision.filters.hours')} · {DEFAULT_HOURS}h
</Badge>
</div>
</div>
{rows.length === 0 && !loading ? (
<p className="text-hint text-xs py-4 text-center">
{t('gearCollision.list.empty', { hours: DEFAULT_HOURS })}
</p>
) : (
<DataTable
data={rows as (GearCollision & Record<string, unknown>)[]}
columns={cols}
pageSize={20}
showSearch={false}
showExport={false}
showPrint={false}
onRowClick={(row) => setSelected(row as GearCollision)}
/>
)}
</Section>
{syncedSelected && (
<Section title={t('gearCollision.detail.title')}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5 text-xs">
<DetailRow label={t('gearCollision.columns.name')} value={syncedSelected.name} mono />
<DetailRow
label={t('gearCollision.columns.mmsiPair')}
value={`${syncedSelected.mmsiLo}${syncedSelected.mmsiHi}`}
mono
/>
<DetailRow
label={t('gearCollision.columns.parentName')}
value={syncedSelected.parentName ?? '-'}
/>
<DetailRow
label={t('gearCollision.detail.firstSeenAt')}
value={formatDateTime(syncedSelected.firstSeenAt)}
/>
<DetailRow
label={t('gearCollision.detail.lastSeenAt')}
value={formatDateTime(syncedSelected.lastSeenAt)}
/>
<DetailRow
label={t('gearCollision.columns.coexistenceCount')}
value={String(syncedSelected.coexistenceCount)}
/>
<DetailRow
label={t('gearCollision.detail.swapCount')}
value={String(syncedSelected.swapCount)}
/>
<DetailRow
label={t('gearCollision.columns.maxDistance')}
value={
syncedSelected.maxDistanceKm != null
? Number(syncedSelected.maxDistanceKm).toFixed(2)
: '-'
}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-label">
{t('gearCollision.columns.severity')}:
</span>
<Badge intent={getAlertLevelIntent(syncedSelected.severity)} size="sm">
{getAlertLevelLabel(syncedSelected.severity, tc, lang)}
</Badge>
<span className="text-xs text-label ml-3">
{t('gearCollision.columns.status')}:
</span>
<Badge intent={getGearCollisionStatusIntent(syncedSelected.status)} size="sm">
{getGearCollisionStatusLabel(syncedSelected.status, t, lang)}
</Badge>
</div>
{syncedSelected.resolutionNote && (
<p className="text-xs text-hint border-l-2 border-border pl-2">
{syncedSelected.resolutionNote}
</p>
)}
<div className="space-y-1.5">
<label
htmlFor="gc-resolve-action"
className="block text-xs text-label"
>
{t('gearCollision.resolve.title')}
</label>
<Select
id="gc-resolve-action"
aria-label={t('gearCollision.resolve.title')}
value={resolveAction}
onChange={(e) => setResolveAction(e.target.value as GearCollisionResolveAction)}
>
<option value="REVIEWED">{t('gearCollision.resolve.reviewed')}</option>
<option value="CONFIRMED_ILLEGAL">
{t('gearCollision.resolve.confirmedIllegal')}
</option>
<option value="FALSE_POSITIVE">
{t('gearCollision.resolve.falsePositive')}
</option>
<option value="REOPEN">{t('gearCollision.resolve.reopen')}</option>
</Select>
<Textarea
aria-label={t('gearCollision.resolve.note')}
placeholder={t('gearCollision.resolve.notePlaceholder')}
value={resolveNote}
onChange={(e) => setResolveNote(e.target.value)}
rows={3}
/>
<div className="flex gap-2 justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => { setSelected(null); setResolveNote(''); }}
>
{t('gearCollision.resolve.cancel')}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleResolve}
disabled={resolving}
>
{t('gearCollision.resolve.submit')}
</Button>
</div>
</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-center 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 GearCollisionDetection;

파일 보기

@ -2,3 +2,4 @@ export { DarkVesselDetection } from './DarkVesselDetection';
export { GearDetection } from './GearDetection'; export { GearDetection } from './GearDetection';
export { ChinaFishing } from './ChinaFishing'; export { ChinaFishing } from './ChinaFishing';
export { GearIdentification } from './GearIdentification'; export { GearIdentification } from './GearIdentification';
export { GearCollisionDetection } from './GearCollisionDetection';

파일 보기

@ -8,6 +8,7 @@
"darkVessel": "Dark Vessel", "darkVessel": "Dark Vessel",
"gearDetection": "Gear Detection", "gearDetection": "Gear Detection",
"chinaFishing": "Chinese Vessel", "chinaFishing": "Chinese Vessel",
"gearCollision": "Gear Collision",
"patrolRoute": "Patrol Route", "patrolRoute": "Patrol Route",
"fleetOptimization": "Fleet Optimize", "fleetOptimization": "Fleet Optimize",
"enforcementHistory": "History", "enforcementHistory": "History",

파일 보기

@ -14,5 +14,77 @@
"gearId": { "gearId": {
"title": "Gear Identification", "title": "Gear Identification",
"desc": "SFR-10 | AI-based gear origin & type automatic identification" "desc": "SFR-10 | AI-based gear origin & type automatic identification"
},
"gearCollision": {
"title": "Gear Identity Collision",
"desc": "Same gear name broadcasting from multiple MMSIs in the same cycle — gear duplication / spoofing suspicion",
"stats": {
"title": "Overview",
"total": "Total",
"open": "Open",
"reviewed": "Reviewed",
"confirmed": "Confirmed Illegal",
"falsePositive": "False Positive"
},
"list": {
"title": "Collision Log",
"empty": "No collisions detected in the last {{hours}} hours.",
"refresh": "Refresh"
},
"columns": {
"name": "Gear Name",
"mmsiPair": "MMSI Pair",
"parentName": "Parent Vessel (est.)",
"coexistenceCount": "Coexistence",
"maxDistance": "Max Distance (km)",
"severity": "Severity",
"status": "Status",
"lastSeen": "Last Seen",
"actions": "Actions"
},
"filters": {
"status": "Status",
"severity": "Severity",
"name": "Search by name",
"hours": "Window (hours)",
"allStatus": "All statuses",
"allSeverity": "All severities"
},
"detail": {
"title": "Collision Detail",
"evidence": "Observations",
"trajectoryCompare": "Trajectory Compare",
"firstSeenAt": "First Seen",
"lastSeenAt": "Last Seen",
"swapCount": "Swap Count"
},
"resolve": {
"title": "Operator Review",
"reviewed": "Mark as Reviewed",
"confirmedIllegal": "Confirm Illegal",
"falsePositive": "Mark False Positive",
"reopen": "Reopen",
"note": "Note",
"notePlaceholder": "Record rationale or supporting evidence",
"submit": "Save",
"cancel": "Cancel",
"confirmPrompt": "Update status to the selected classification. Continue?"
},
"status": {
"open": "Open",
"reviewed": "Reviewed",
"confirmedIllegal": "Confirmed Illegal",
"falsePositive": "False Positive"
},
"severity": {
"CRITICAL": "Critical",
"HIGH": "High",
"MEDIUM": "Medium",
"LOW": "Low"
},
"error": {
"loadFailed": "Failed to load collisions",
"resolveFailed": "Failed to save classification"
}
} }
} }

파일 보기

@ -8,6 +8,7 @@
"darkVessel": "다크베셀 탐지", "darkVessel": "다크베셀 탐지",
"gearDetection": "어구 탐지", "gearDetection": "어구 탐지",
"chinaFishing": "중국어선 분석", "chinaFishing": "중국어선 분석",
"gearCollision": "어구 정체성 충돌",
"patrolRoute": "순찰경로 추천", "patrolRoute": "순찰경로 추천",
"fleetOptimization": "다함정 최적화", "fleetOptimization": "다함정 최적화",
"enforcementHistory": "단속 이력", "enforcementHistory": "단속 이력",

파일 보기

@ -14,5 +14,77 @@
"gearId": { "gearId": {
"title": "어구 식별 분석", "title": "어구 식별 분석",
"desc": "SFR-10 | AI 기반 어구 원산지·유형 자동 식별 및 판정" "desc": "SFR-10 | AI 기반 어구 원산지·유형 자동 식별 및 판정"
},
"gearCollision": {
"title": "어구 정체성 충돌 탐지",
"desc": "동일 어구 이름이 서로 다른 MMSI 로 같은 사이클에 동시 송출되는 공존 패턴 — 어구 복제/스푸핑 의심",
"stats": {
"title": "현황 요약",
"total": "전체",
"open": "미검토",
"reviewed": "검토됨",
"confirmed": "불법 확정",
"falsePositive": "오탐"
},
"list": {
"title": "충돌 이력",
"empty": "최근 {{hours}}시간 내 감지된 충돌이 없습니다.",
"refresh": "새로고침"
},
"columns": {
"name": "어구명",
"mmsiPair": "MMSI 쌍",
"parentName": "추정 모선",
"coexistenceCount": "공존 횟수",
"maxDistance": "최대 거리(km)",
"severity": "심각도",
"status": "상태",
"lastSeen": "마지막 감지",
"actions": "액션"
},
"filters": {
"status": "상태",
"severity": "심각도",
"name": "어구명 검색",
"hours": "조회 기간(시간)",
"allStatus": "전체 상태",
"allSeverity": "전체 심각도"
},
"detail": {
"title": "공존 상세",
"evidence": "관측 이력",
"trajectoryCompare": "궤적 비교",
"firstSeenAt": "최초 감지",
"lastSeenAt": "마지막 감지",
"swapCount": "교체 누적"
},
"resolve": {
"title": "운영자 분류",
"reviewed": "검토 완료",
"confirmedIllegal": "불법으로 확정",
"falsePositive": "오탐으로 분류",
"reopen": "재오픈",
"note": "판정 메모",
"notePlaceholder": "분류 사유·추가 증거 등을 기록하세요",
"submit": "저장",
"cancel": "취소",
"confirmPrompt": "선택한 분류로 상태를 갱신합니다. 계속할까요?"
},
"status": {
"open": "미검토",
"reviewed": "검토됨",
"confirmedIllegal": "불법 확정",
"falsePositive": "오탐"
},
"severity": {
"CRITICAL": "매우 심각",
"HIGH": "심각",
"MEDIUM": "주의",
"LOW": "경미"
},
"error": {
"loadFailed": "충돌 목록을 불러오지 못했습니다",
"resolveFailed": "분류 저장에 실패했습니다"
}
} }
} }

파일 보기

@ -0,0 +1,114 @@
/**
* gear_identity_collisions + API .
* /api/analysis/gear-collisions .
*/
import type { AnalysisPageResponse } from './analysisApi';
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
// ─── DTO (백엔드 GearCollisionResponse 1:1 매핑) ─────────────
export interface GearCollision {
id: number;
name: string;
mmsiLo: string;
mmsiHi: string;
parentName: string | null;
parentVesselId: number | null;
firstSeenAt: string;
lastSeenAt: string;
coexistenceCount: number;
swapCount: number;
maxDistanceKm: number | null;
lastLatLo: number | null;
lastLonLo: number | null;
lastLatHi: number | null;
lastLonHi: number | null;
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | string;
status: 'OPEN' | 'REVIEWED' | 'CONFIRMED_ILLEGAL' | 'FALSE_POSITIVE' | string;
resolutionNote: string | null;
evidence: Array<Record<string, unknown>> | null;
updatedAt: string;
}
export interface GearCollisionStats {
total: number;
byStatus: Record<string, number>;
bySeverity: Record<string, number>;
hours: number;
}
export type GearCollisionResolveAction =
| 'REVIEWED'
| 'CONFIRMED_ILLEGAL'
| 'FALSE_POSITIVE'
| 'REOPEN';
export interface GearCollisionResolveRequest {
action: GearCollisionResolveAction;
note?: string;
}
// ─── 내부 헬퍼 ─────────────
function buildQuery(params: Record<string, unknown>): string {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === '') continue;
qs.set(k, String(v));
}
const s = qs.toString();
return s ? `?${s}` : '';
}
async function apiGet<T>(path: string, params: Record<string, unknown> = {}): Promise<T> {
const res = await fetch(`${API_BASE}${path}${buildQuery(params)}`, { credentials: 'include' });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
async function apiPost<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
// ─── 공개 함수 ─────────────
/** 어구 정체성 충돌 목록 조회 (필터 + 페이징). */
export function listGearCollisions(params?: {
status?: string;
severity?: string;
name?: string;
hours?: number;
page?: number;
size?: number;
}): Promise<AnalysisPageResponse<GearCollision>> {
return apiGet('/analysis/gear-collisions', {
hours: 48, page: 0, size: 50, ...params,
});
}
/** status/severity 집계 */
export function getGearCollisionStats(hours = 48): Promise<GearCollisionStats> {
return apiGet('/analysis/gear-collisions/stats', { hours });
}
/** 단건 상세 조회 */
export function getGearCollision(id: number): Promise<GearCollision> {
return apiGet(`/analysis/gear-collisions/${id}`);
}
/** 운영자 분류 액션 */
export function resolveGearCollision(
id: number,
body: GearCollisionResolveRequest,
): Promise<GearCollision> {
return apiPost(`/analysis/gear-collisions/${id}/resolve`, body);
}

파일 보기

@ -45,6 +45,7 @@ import { ZONE_CODES } from './zoneCodes';
import { GEAR_VIOLATION_CODES } from './gearViolationCodes'; import { GEAR_VIOLATION_CODES } from './gearViolationCodes';
import { VESSEL_TYPES } from './vesselTypes'; import { VESSEL_TYPES } from './vesselTypes';
import { PERFORMANCE_STATUS_META } from './performanceStatus'; import { PERFORMANCE_STATUS_META } from './performanceStatus';
import { GEAR_COLLISION_STATUSES } from './gearCollisionStatuses';
/** /**
* UI * UI
@ -337,6 +338,15 @@ export const CATALOG_REGISTRY: CatalogEntry[] = [
source: 'admin 성능·데이터 보관·모델 검증 공통', source: 'admin 성능·데이터 보관·모델 검증 공통',
items: PERFORMANCE_STATUS_META, items: PERFORMANCE_STATUS_META,
}, },
{
id: 'gear-collision-status',
showcaseId: 'TRK-CAT-gear-collision-status',
titleKo: '어구 정체성 충돌 상태',
titleEn: 'Gear Identity Collision Status',
description: 'OPEN / REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE',
source: 'backend gear_identity_collisions.status (V030)',
items: GEAR_COLLISION_STATUSES,
},
]; ];
/** ID로 특정 카탈로그 조회 */ /** ID로 특정 카탈로그 조회 */

파일 보기

@ -0,0 +1,74 @@
/**
* (GEAR_IDENTITY_COLLISION)
*
* SSOT: backend GearIdentityCollision.status (V030 )
* 사용처: GearCollisionDetection / Badge
*/
import type { BadgeIntent } from '@lib/theme/variants';
export type GearCollisionStatus =
| 'OPEN' // 신규 탐지 (미검토)
| 'REVIEWED' // 검토됨 (확정 보류)
| 'CONFIRMED_ILLEGAL' // 불법 확정
| 'FALSE_POSITIVE'; // 오탐 처리
export interface GearCollisionStatusMeta {
code: GearCollisionStatus;
i18nKey: string;
fallback: { ko: string; en: string };
intent: BadgeIntent;
}
export const GEAR_COLLISION_STATUSES: Record<GearCollisionStatus, GearCollisionStatusMeta> = {
OPEN: {
code: 'OPEN',
i18nKey: 'gearCollision.status.open',
fallback: { ko: '미검토', en: 'Open' },
intent: 'warning',
},
REVIEWED: {
code: 'REVIEWED',
i18nKey: 'gearCollision.status.reviewed',
fallback: { ko: '검토됨', en: 'Reviewed' },
intent: 'info',
},
CONFIRMED_ILLEGAL: {
code: 'CONFIRMED_ILLEGAL',
i18nKey: 'gearCollision.status.confirmedIllegal',
fallback: { ko: '불법 확정', en: 'Confirmed Illegal' },
intent: 'critical',
},
FALSE_POSITIVE: {
code: 'FALSE_POSITIVE',
i18nKey: 'gearCollision.status.falsePositive',
fallback: { ko: '오탐', en: 'False Positive' },
intent: 'muted',
},
};
export function getGearCollisionStatusMeta(s: string): GearCollisionStatusMeta | undefined {
return GEAR_COLLISION_STATUSES[s as GearCollisionStatus];
}
export function getGearCollisionStatusIntent(s: string): BadgeIntent {
return getGearCollisionStatusMeta(s)?.intent ?? 'muted';
}
export function getGearCollisionStatusLabel(
s: string,
t: (key: string, opts?: Record<string, unknown>) => string,
lang: 'ko' | 'en' = 'ko',
): string {
const meta = getGearCollisionStatusMeta(s);
if (!meta) return s;
const translated = t(meta.i18nKey, { defaultValue: '' });
return translated || meta.fallback[lang];
}
export const GEAR_COLLISION_STATUS_ORDER: GearCollisionStatus[] = [
'OPEN',
'REVIEWED',
'CONFIRMED_ILLEGAL',
'FALSE_POSITIVE',
];

파일 보기

@ -0,0 +1,157 @@
"""
어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 알고리즘.
동일 어구 이름이 서로 다른 MMSI 같은 5 사이클 동시 AIS 송출되는 케이스를
스푸핑/복제 의심 패턴으로 탐지한다. fleet_tracker.track_gear_identity() 루프 진입
전에 사이클 단위로 사전 집계하는 사용된다.
"""
from datetime import datetime
from itertools import combinations
from typing import Optional
from algorithms.location import haversine_nm
# ──────────────────────────────────────────────────────────────────
# 공존 판정 · 심각도 임계
# ──────────────────────────────────────────────────────────────────
MIN_COEXISTENCE_GROUP = 2 # 같은 이름에 MMSI 2개 이상
IMPOSSIBLE_SPEED_KTS = 60.0 # 두 위치 이동에 필요한 속도가 이보다 크면 물리 불가능
CRITICAL_DISTANCE_KM = 50.0 # 단발이라도 이 거리 이상이면 즉시 CRITICAL
HIGH_DISTANCE_KM = 10.0 # HIGH 기준 거리
CRITICAL_COEXISTENCE_COUNT = 3 # 누적 공존 N회 이상이면 CRITICAL 승격
HIGH_COEXISTENCE_COUNT = 2 # 누적 공존 N회 이상이면 HIGH
NM_TO_KM = 1.852 # 1 nautical mile = 1.852 km
def detect_gear_name_collisions(
gear_signals: list[dict],
now: datetime,
) -> list[dict]:
"""동일 이름 · 다중 MMSI 공존 세트 추출.
Args:
gear_signals: [{mmsi, name, lat, lon}, ...] track_gear_identity 동일 입력.
now: 사이클 기준 시각(UTC).
Returns:
공존 리스트. 이상 동시 송출 케이스는 모든 2-조합을 생성한다.
원소:
{
'name': str,
'mmsi_lo': str, # 사전순으로 작은 MMSI
'mmsi_hi': str,
'lat_lo', 'lon_lo': float,
'lat_hi', 'lon_hi': float,
'distance_km': float,
'parent_name': Optional[str], # 힌트 (GEAR_PATTERN parent 그룹, 있으면)
'observed_at': datetime,
}
"""
if not gear_signals:
return []
# 이름 기준 그룹핑
by_name: dict[str, list[dict]] = {}
for sig in gear_signals:
name = sig.get('name')
mmsi = sig.get('mmsi')
if not name or not mmsi:
continue
by_name.setdefault(name, []).append(sig)
collisions: list[dict] = []
for name, signals in by_name.items():
if len(signals) < MIN_COEXISTENCE_GROUP:
continue
# 같은 MMSI 중복은 제거 (한 cycle 에 동일 MMSI 가 다수 신호로 들어올 수 있음)
unique_by_mmsi: dict[str, dict] = {}
for sig in signals:
unique_by_mmsi.setdefault(sig['mmsi'], sig)
if len(unique_by_mmsi) < MIN_COEXISTENCE_GROUP:
continue
parent_name = _infer_parent_name(name)
mmsis = sorted(unique_by_mmsi.keys())
for a, b in combinations(mmsis, 2):
sa, sb = unique_by_mmsi[a], unique_by_mmsi[b]
dist_km = _haversine_km(
sa.get('lat'), sa.get('lon'),
sb.get('lat'), sb.get('lon'),
)
collisions.append({
'name': name,
'mmsi_lo': a,
'mmsi_hi': b,
'lat_lo': _to_float(sa.get('lat')),
'lon_lo': _to_float(sa.get('lon')),
'lat_hi': _to_float(sb.get('lat')),
'lon_hi': _to_float(sb.get('lon')),
'distance_km': dist_km,
'parent_name': parent_name,
'observed_at': now,
})
return collisions
def classify_severity(
coexistence_count: int,
max_distance_km: Optional[float],
swap_count: int = 0,
) -> str:
"""충돌 심각도 산정.
- CRITICAL: 거리 불가능 / 누적 공존 N회 이상
- HIGH: 상당 거리 / 2 이상
- MEDIUM: 단발 근거리
- LOW: 근거리 + 거리 정보 없음
"""
distance = max_distance_km or 0.0
if distance >= CRITICAL_DISTANCE_KM:
return 'CRITICAL'
if coexistence_count >= CRITICAL_COEXISTENCE_COUNT:
return 'CRITICAL'
if distance >= HIGH_DISTANCE_KM:
return 'HIGH'
if coexistence_count >= HIGH_COEXISTENCE_COUNT:
return 'HIGH'
if swap_count >= HIGH_COEXISTENCE_COUNT:
return 'HIGH'
if max_distance_km is None or max_distance_km < 0.1:
return 'LOW'
return 'MEDIUM'
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
"""두 좌표 사이 거리를 km 로 반환. 입력 누락 시 0.0."""
try:
if lat1 is None or lon1 is None or lat2 is None or lon2 is None:
return 0.0
nm = haversine_nm(float(lat1), float(lon1), float(lat2), float(lon2))
return round(nm * NM_TO_KM, 2)
except (TypeError, ValueError):
return 0.0
def _to_float(val) -> Optional[float]:
if val is None:
return None
try:
return float(val)
except (TypeError, ValueError):
return None
def _infer_parent_name(gear_name: str) -> Optional[str]:
"""어구 이름에서 모선명 부분 추출 (느슨).
fleet_tracker 이미 GEAR_PATTERN 으로 정교하게 파싱하지만, 알고리즘 모듈 독립성을
위해 단순 휴리스틱만 유지한다. 값이 필요한 경우 fleet_tracker 호출부에서 덮어쓴다.
"""
if not gear_name:
return None
# '_숫자' 로 끝나는 서픽스 제거
base = gear_name
parts = base.rsplit('_', 2)
if len(parts) >= 2 and any(ch.isdigit() for ch in parts[-1]):
return parts[0]
return None

파일 보기

@ -1,4 +1,5 @@
"""등록 선단 기반 추적기.""" """등록 선단 기반 추적기."""
import json
import logging import logging
import re import re
import time import time
@ -6,7 +7,9 @@ from datetime import datetime, timezone
from typing import Optional from typing import Optional
import pandas as pd import pandas as pd
import psycopg2
from algorithms.gear_identity import classify_severity, detect_gear_name_collisions
from algorithms.gear_name_rules import is_trackable_parent_name from algorithms.gear_name_rules import is_trackable_parent_name
from config import qualified_table from config import qualified_table
@ -21,6 +24,7 @@ FLEET_COMPANIES = qualified_table('fleet_companies')
FLEET_VESSELS = qualified_table('fleet_vessels') FLEET_VESSELS = qualified_table('fleet_vessels')
GEAR_IDENTITY_LOG = qualified_table('gear_identity_log') GEAR_IDENTITY_LOG = qualified_table('gear_identity_log')
GEAR_CORRELATION_SCORES = qualified_table('gear_correlation_scores') GEAR_CORRELATION_SCORES = qualified_table('gear_correlation_scores')
GEAR_IDENTITY_COLLISIONS = qualified_table('gear_identity_collisions')
FLEET_TRACKING_SNAPSHOT = qualified_table('fleet_tracking_snapshot') FLEET_TRACKING_SNAPSHOT = qualified_table('fleet_tracking_snapshot')
# 선박명 정규화 (중국/한국 어선 식별자 보존): # 선박명 정규화 (중국/한국 어선 식별자 보존):
@ -341,61 +345,40 @@ class FleetTracker:
"""어구/어망 정체성 추적. """어구/어망 정체성 추적.
gear_signals: [{mmsi, name, lat, lon}, ...] 이름이 XXX_숫자_숫자 패턴인 AIS 신호 gear_signals: [{mmsi, name, lat, lon}, ...] 이름이 XXX_숫자_숫자 패턴인 AIS 신호
동일 이름이 서로 다른 MMSI 같은 cycle 동시에 수신된 경우는 "공존
(GEAR_IDENTITY_COLLISION)" 으로 간주해 gear_identity_collisions 에 누적하고,
gear_identity_log MMSI 별로 독립 active 유지한다 (비활성화/점수 이전 X).
단일 MMSI 이름에 한해 기존 교체(sequential) 로직을 적용한다.
""" """
cur = conn.cursor() cur = conn.cursor()
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
self._recent_collision_ids: list[int] = []
# 1) 공존 감지 + UPSERT (이번 사이클에 동일 이름 다중 MMSI 수신 확인)
collisions = detect_gear_name_collisions(gear_signals, now)
colliding_names: set[str] = {c['name'] for c in collisions}
for c in collisions:
cid = self._upsert_gear_collision(cur, c, now)
if cid is not None:
self._recent_collision_ids.append(cid)
# 2) 개별 신호 처리
for g in gear_signals: for g in gear_signals:
mmsi = g['mmsi'] mmsi = g['mmsi']
name = g['name'] name = g['name']
lat = g.get('lat', 0) lat = g.get('lat', 0)
lon = g.get('lon', 0) lon = g.get('lon', 0)
# 모선명 + 인덱스 추출 parent_name, idx1, idx2 = self._parse_gear_name(name)
parent_name: Optional[str] = None
idx1: Optional[int] = None
idx2: Optional[int] = None
m = GEAR_PATTERN.match(name)
if m:
# group(1): parent+index 패턴, group(2): 순수 숫자 패턴
if m.group(1):
parent_name = m.group(1).strip()
suffix = name[m.end(1):].strip(' _')
digits = re.findall(r'\d+', suffix)
idx1 = int(digits[0]) if len(digits) >= 1 else None
idx2 = int(digits[1]) if len(digits) >= 2 else None
else:
# 순수 숫자 이름 (예: 12345) — parent 없음, 인덱스만
idx1 = int(m.group(2))
else:
m2 = GEAR_PATTERN_PCT.match(name)
if m2:
parent_name = m2.group(1).strip()
effective_parent_name = parent_name or name effective_parent_name = parent_name or name
if not is_trackable_parent_name(effective_parent_name): if not is_trackable_parent_name(effective_parent_name):
continue continue
# 모선 매칭 (EXACT → FUZZY 순) parent_mmsi, parent_vid = self._match_parent_vessel(parent_name)
parent_mmsi: Optional[str] = None
parent_vid: Optional[int] = None
if parent_name:
vid = self._name_cn_map.get(parent_name)
if not vid:
vid = self._name_en_map.get(parent_name.lower())
if not vid:
key = _normalize_vessel_name(parent_name)
if key:
candidates = self._name_fuzzy_map.get(key, [])
if len(candidates) == 1:
vid = candidates[0]
if vid:
parent_vid = vid
parent_mmsi = self._vessels[vid].get('mmsi')
match_method: Optional[str] = 'NAME_PARENT' if parent_vid else None match_method: Optional[str] = 'NAME_PARENT' if parent_vid else None
confidence = 0.9 if parent_vid else 0.0 confidence = 0.9 if parent_vid else 0.0
is_colliding = name in colliding_names
# 기존 활성 행 조회 # 기존 활성 행 조회
cur.execute( cur.execute(
@ -431,7 +414,9 @@ class FleetTracker:
match_method, confidence, now, now), match_method, confidence, now, now),
) )
else: else:
# 새 MMSI → 같은 이름이 다른 MMSI로 있는지 확인 # 새 MMSI — 이름이 공존 케이스면 다른 MMSI 활성행을 건드리지 않고 이번 것만 INSERT
if not is_colliding:
# 교체 경로 — 같은 이름이 다른 MMSI 로 active 면 MMSI 교체로 간주
cur.execute( cur.execute(
f"""SELECT id, mmsi FROM {GEAR_IDENTITY_LOG} f"""SELECT id, mmsi FROM {GEAR_IDENTITY_LOG}
WHERE name = %s AND is_active = TRUE AND mmsi != %s""", WHERE name = %s AND is_active = TRUE AND mmsi != %s""",
@ -439,28 +424,16 @@ class FleetTracker:
) )
old_mmsi_row = cur.fetchone() old_mmsi_row = cur.fetchone()
if old_mmsi_row: if old_mmsi_row:
# 같은 이름 + 다른 MMSI → MMSI 변경
cur.execute( cur.execute(
f'UPDATE {GEAR_IDENTITY_LOG} SET is_active = FALSE WHERE id = %s', f'UPDATE {GEAR_IDENTITY_LOG} SET is_active = FALSE WHERE id = %s',
(old_mmsi_row[0],), (old_mmsi_row[0],),
) )
logger.info('gear MMSI change: %s%s (name=%s)', old_mmsi_row[1], mmsi, name)
# 어피니티 점수 이전 (이전 MMSI → 새 MMSI)
try:
cur.execute(
f"UPDATE {GEAR_CORRELATION_SCORES} "
"SET target_mmsi = %s, updated_at = NOW() "
"WHERE target_mmsi = %s",
(mmsi, old_mmsi_row[1]),
)
if cur.rowcount > 0:
logger.info( logger.info(
'transferred %d affinity scores: %s%s', 'gear MMSI change: %s%s (name=%s)',
cur.rowcount, old_mmsi_row[1], mmsi, old_mmsi_row[1], mmsi, name,
) )
except Exception as e: # 어피니티 점수 이전 (savepoint 로 격리 — PK 충돌 시 트랜잭션 유지)
logger.warning('affinity score transfer failed: %s', e) self._transfer_affinity_scores(cur, old_mmsi_row[1], mmsi)
cur.execute( cur.execute(
f"""INSERT INTO {GEAR_IDENTITY_LOG} f"""INSERT INTO {GEAR_IDENTITY_LOG}
@ -473,9 +446,190 @@ class FleetTracker:
match_method, confidence, now, now), match_method, confidence, now, now),
) )
if collisions:
logger.info(
'gear identity collisions: %d pairs touched (%d distinct names)',
len(collisions), len(colliding_names),
)
conn.commit() conn.commit()
cur.close() cur.close()
def _parse_gear_name(self, name: str) -> tuple[Optional[str], Optional[int], Optional[int]]:
"""어구 이름에서 parent_name, idx1, idx2 추출."""
parent_name: Optional[str] = None
idx1: Optional[int] = None
idx2: Optional[int] = None
m = GEAR_PATTERN.match(name)
if m:
if m.group(1):
parent_name = m.group(1).strip()
suffix = name[m.end(1):].strip(' _')
digits = re.findall(r'\d+', suffix)
idx1 = int(digits[0]) if len(digits) >= 1 else None
idx2 = int(digits[1]) if len(digits) >= 2 else None
else:
idx1 = int(m.group(2))
else:
m2 = GEAR_PATTERN_PCT.match(name)
if m2:
parent_name = m2.group(1).strip()
return parent_name, idx1, idx2
def _match_parent_vessel(self, parent_name: Optional[str]) -> tuple[Optional[str], Optional[int]]:
"""parent_name → (parent_mmsi, parent_vessel_id) 매칭. EXACT → FUZZY 순."""
if not parent_name:
return None, None
vid = self._name_cn_map.get(parent_name)
if not vid:
vid = self._name_en_map.get(parent_name.lower())
if not vid:
key = _normalize_vessel_name(parent_name)
if key:
candidates = self._name_fuzzy_map.get(key, [])
if len(candidates) == 1:
vid = candidates[0]
if vid:
return self._vessels[vid].get('mmsi'), vid
return None, None
def _transfer_affinity_scores(self, cur, old_mmsi: str, new_mmsi: str) -> None:
"""gear_correlation_scores 의 target_mmsi 를 새 MMSI 로 이전.
PK = (model_id, group_key, sub_cluster_id, target_mmsi) 충돌 가능성이 있어
SAVEPOINT 격리. 충돌 시에는 이전을 포기하고 경고만 남긴다 상위 트랜잭션은
유지되어 뒤이은 INSERT 정상 동작한다.
"""
cur.execute('SAVEPOINT sp_score_xfer')
try:
cur.execute(
f"UPDATE {GEAR_CORRELATION_SCORES} "
"SET target_mmsi = %s, updated_at = NOW() "
"WHERE target_mmsi = %s",
(new_mmsi, old_mmsi),
)
if cur.rowcount > 0:
logger.info(
'transferred %d affinity scores: %s%s',
cur.rowcount, old_mmsi, new_mmsi,
)
cur.execute('RELEASE SAVEPOINT sp_score_xfer')
except psycopg2.errors.UniqueViolation as e:
cur.execute('ROLLBACK TO SAVEPOINT sp_score_xfer')
cur.execute('RELEASE SAVEPOINT sp_score_xfer')
logger.warning(
'affinity score transfer skipped (pk conflict): %s%s | %s',
old_mmsi, new_mmsi, e,
)
def _upsert_gear_collision(
self,
cur,
collision: dict,
now: datetime,
) -> Optional[int]:
"""단일 공존 쌍 UPSERT → row id 반환.
- 기존 : coexistence_count += 1, last_seen_at/좌표 갱신, max_distance_km =
GREATEST(기존, 신규). status OPEN/REVIEWED 경우에 한해 severity 재계산.
- 신규 : first_seen_at = last_seen_at = now, severity 계산 INSERT.
"""
name = collision['name']
mmsi_lo = collision['mmsi_lo']
mmsi_hi = collision['mmsi_hi']
distance_km = collision.get('distance_km') or 0.0
# 모선 힌트 — fleet_tracker 매칭 결과를 우선, 없으면 알고리즘이 추출한 값 사용
hint_parent = collision.get('parent_name')
parent_mmsi, parent_vid = self._match_parent_vessel(hint_parent)
cur.execute(
f"""SELECT id, coexistence_count, max_distance_km, swap_count,
status, parent_name, parent_vessel_id
FROM {GEAR_IDENTITY_COLLISIONS}
WHERE name = %s AND mmsi_lo = %s AND mmsi_hi = %s""",
(name, mmsi_lo, mmsi_hi),
)
row = cur.fetchone()
evidence_fragment = {
'observed_at': now.isoformat(),
'distance_km': distance_km,
'positions': {
mmsi_lo: {
'lat': collision.get('lat_lo'),
'lon': collision.get('lon_lo'),
},
mmsi_hi: {
'lat': collision.get('lat_hi'),
'lon': collision.get('lon_hi'),
},
},
}
if row:
(row_id, cur_count, cur_max_dist, swap_cnt,
cur_status, cur_parent_name, cur_parent_vid) = row
new_count = (cur_count or 0) + 1
merged_max = max(
float(cur_max_dist or 0),
float(distance_km or 0),
)
if cur_status in ('CONFIRMED_ILLEGAL', 'FALSE_POSITIVE'):
new_severity = None # 확정 상태는 유지
else:
new_severity = classify_severity(new_count, merged_max, swap_cnt or 0)
cur.execute(
f"""UPDATE {GEAR_IDENTITY_COLLISIONS}
SET last_seen_at = %s,
coexistence_count = %s,
max_distance_km = %s,
last_lat_lo = %s,
last_lon_lo = %s,
last_lat_hi = %s,
last_lon_hi = %s,
parent_name = COALESCE(%s, parent_name),
parent_vessel_id = COALESCE(%s, parent_vessel_id),
severity = COALESCE(%s, severity),
evidence = COALESCE(evidence, '[]'::jsonb)
|| to_jsonb(%s::jsonb),
updated_at = NOW()
WHERE id = %s""",
(
now, new_count, merged_max,
collision.get('lat_lo'), collision.get('lon_lo'),
collision.get('lat_hi'), collision.get('lon_hi'),
hint_parent, parent_vid,
new_severity,
json.dumps([evidence_fragment]),
row_id,
),
)
return row_id
severity = classify_severity(1, distance_km, 0)
cur.execute(
f"""INSERT INTO {GEAR_IDENTITY_COLLISIONS}
(name, mmsi_lo, mmsi_hi, parent_name, parent_vessel_id,
first_seen_at, last_seen_at,
coexistence_count, max_distance_km,
last_lat_lo, last_lon_lo, last_lat_hi, last_lon_hi,
severity, status, evidence)
VALUES (%s,%s,%s,%s,%s,%s,%s,1,%s,%s,%s,%s,%s,%s,'OPEN',%s)
RETURNING id""",
(
name, mmsi_lo, mmsi_hi, hint_parent, parent_vid,
now, now, distance_km,
collision.get('lat_lo'), collision.get('lon_lo'),
collision.get('lat_hi'), collision.get('lon_hi'),
severity,
json.dumps([evidence_fragment]),
),
)
return cur.fetchone()[0]
def get_recent_collision_ids(self) -> list[int]:
"""이번 사이클에 갱신·추가된 gear_identity_collisions row id 목록."""
return list(getattr(self, '_recent_collision_ids', []))
def build_fleet_clusters(self, vessel_dfs: dict[str, pd.DataFrame]) -> dict[str, dict]: def build_fleet_clusters(self, vessel_dfs: dict[str, pd.DataFrame]) -> dict[str, dict]:
"""등록 선단 기준으로 cluster 정보 구성. """등록 선단 기준으로 cluster 정보 구성.

파일 보기

@ -19,6 +19,7 @@ from db.kcgdb import get_conn
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
EVENTS_TABLE = qualified_table('prediction_events') EVENTS_TABLE = qualified_table('prediction_events')
GEAR_IDENTITY_COLLISIONS_TABLE = qualified_table('gear_identity_collisions')
# 카테고리별 dedup 윈도우 (분). # 카테고리별 dedup 윈도우 (분).
# 사이클이 5분 간격이므로 5의 배수를 피해서 boundary 일제 만료 패턴을 회피한다. # 사이클이 5분 간격이므로 5의 배수를 피해서 boundary 일제 만료 패턴을 회피한다.
@ -34,6 +35,7 @@ DEDUP_WINDOWS = {
'GEAR_ILLEGAL': 367, 'GEAR_ILLEGAL': 367,
'AIS_RESUME': 67, 'AIS_RESUME': 67,
'HIGH_RISK_VESSEL': 67, 'HIGH_RISK_VESSEL': 67,
'GEAR_IDENTITY_COLLISION': 367, # 같은 쌍의 반복 공존은 장기 이벤트로 집계
} }
# 이벤트 생성 룰 # 이벤트 생성 룰
@ -294,3 +296,123 @@ def run_event_generator(analysis_results: list[dict]) -> dict:
logger.info(f'event_generator: generated={generated}, skipped_dedup={skipped_dedup}') logger.info(f'event_generator: generated={generated}, skipped_dedup={skipped_dedup}')
return {'generated': generated, 'skipped_dedup': skipped_dedup} return {'generated': generated, 'skipped_dedup': skipped_dedup}
_COLLISION_EVENT_LEVELS = {'CRITICAL', 'HIGH'}
def run_gear_identity_collision_events(collision_ids: list[int]) -> dict:
"""gear_identity_collisions 의 갱신 행을 prediction_events 로 승격.
이번 cycle 갱신·추가된 row severity CRITICAL/HIGH 이고 status=OPEN 것만
이벤트 허브에 등록한다. dedup 키는 `{name}:{mmsi_lo}:{mmsi_hi}` 기준으로 장기 윈도우.
Args:
collision_ids: fleet_tracker.get_recent_collision_ids() 돌려준 id 목록
Returns:
{ 'generated': int, 'skipped_dedup': int, 'skipped_low': int }
"""
if not collision_ids:
return {'generated': 0, 'skipped_dedup': 0, 'skipped_low': 0}
now = datetime.now(timezone.utc)
generated = 0
skipped_dedup = 0
skipped_low = 0
events_to_insert: list[tuple] = []
with get_conn() as conn:
date_str = now.strftime('%Y%m%d')
seq = _get_next_seq(conn, date_str)
cur = conn.cursor()
cur.execute(
f"""SELECT id, name, mmsi_lo, mmsi_hi, parent_name,
coexistence_count, max_distance_km, severity, status,
last_lat_lo, last_lon_lo
FROM {GEAR_IDENTITY_COLLISIONS_TABLE}
WHERE id = ANY(%s)""",
(list(collision_ids),),
)
rows = cur.fetchall()
for (cid, name, mmsi_lo, mmsi_hi, parent_name,
coexist_count, max_dist, severity, status,
lat_lo, lon_lo) in rows:
if status != 'OPEN':
skipped_low += 1
continue
if severity not in _COLLISION_EVENT_LEVELS:
skipped_low += 1
continue
category = 'GEAR_IDENTITY_COLLISION'
dedup_key = f"{name}:{mmsi_lo}:{mmsi_hi}:{category}"
if _check_dedup(conn, dedup_key, category, now):
skipped_dedup += 1
continue
event_uid = _make_event_uid(now, seq)
seq += 1
distance_txt = f"{float(max_dist or 0):.1f}km"
title = (
f"어구 정체성 충돌: {name} "
f"({mmsi_lo}{mmsi_hi}, 공존 {coexist_count}회, 거리 {distance_txt})"
)
features = {
'collision_id': cid,
'name': name,
'mmsi_lo': mmsi_lo,
'mmsi_hi': mmsi_hi,
'coexistence_count': coexist_count,
'max_distance_km': float(max_dist or 0),
'parent_name': parent_name,
}
events_to_insert.append((
event_uid,
now,
severity,
category,
title,
None, # detail
mmsi_lo, # 대표 MMSI (검색 편의)
parent_name, # vessel_name
None, # area_name
None, # zone_code
float(lat_lo) if lat_lo is not None else None,
float(lon_lo) if lon_lo is not None else None,
None, # speed_kn
'GEAR_COLLISION', # source_type
cid, # source_ref_id
None, # ai_confidence
'NEW',
dedup_key,
json.dumps(features, ensure_ascii=False, default=str),
))
generated += 1
if events_to_insert:
execute_values(
conn.cursor(),
f"""INSERT INTO {EVENTS_TABLE}
(event_uid, occurred_at, level, category, title, detail,
vessel_mmsi, vessel_name, area_name, zone_code, lat, lon, speed_kn,
source_type, source_ref_id, ai_confidence, status, dedup_key, features)
VALUES %s
ON CONFLICT (event_uid) DO NOTHING""",
events_to_insert,
)
conn.commit()
logger.info(
'gear_collision_events: generated=%d, skipped_dedup=%d, skipped_low=%d',
generated, skipped_dedup, skipped_low,
)
return {
'generated': generated,
'skipped_dedup': skipped_dedup,
'skipped_low': skipped_low,
}

파일 보기

@ -157,6 +157,21 @@ def run_analysis_cycle():
gear_signals = [v for v in all_ais if GEAR_PATTERN.match(v.get('name', '') or '')] gear_signals = [v for v in all_ais if GEAR_PATTERN.match(v.get('name', '') or '')]
fleet_tracker.track_gear_identity(gear_signals, kcg_conn) fleet_tracker.track_gear_identity(gear_signals, kcg_conn)
# 이번 사이클에 갱신된 어구 정체성 충돌을 이벤트 허브로 승격 (CRITICAL/HIGH 만)
collision_ids = fleet_tracker.get_recent_collision_ids()
if collision_ids:
try:
from output.event_generator import run_gear_identity_collision_events
collision_events = run_gear_identity_collision_events(collision_ids)
logger.info(
'gear collision events: generated=%d, skipped_dedup=%d, skipped_low=%d',
collision_events['generated'],
collision_events['skipped_dedup'],
collision_events['skipped_low'],
)
except Exception as e:
logger.warning('gear collision event promotion failed: %s', e)
fleet_roles = fleet_tracker.build_fleet_clusters(vessel_dfs) fleet_roles = fleet_tracker.build_fleet_clusters(vessel_dfs)
fleet_tracker.save_snapshot(vessel_dfs, kcg_conn) fleet_tracker.save_snapshot(vessel_dfs, kcg_conn)