feat(backend): 워크플로우 연결 Step 1 — 백엔드 기반 확장
- V018 마이그레이션: prediction_events.features JSONB 컬럼 추가
- VesselAnalysis 직접 조회 API 5개 신설 (/api/analysis/*)
- vessels 목록 (필터: mmsi, zone, riskLevel, isDark)
- vessels/{mmsi} 최신 분석 (features 포함)
- vessels/{mmsi}/history 분석 이력
- dark 베셀 목록 (MMSI 중복 제거)
- transship 의심 목록
- PredictionEvent entity에 features JSONB 필드 추가
- EnforcementController vesselMmsi 필터 파라미터 추가
- event_generator.py INSERT에 features 컬럼 추가
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
13ea475bd7
커밋
5c804aa38f
@ -0,0 +1,99 @@
|
||||
package gc.mda.kcg.domain.analysis;
|
||||
|
||||
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||
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.*;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* vessel_analysis_results 직접 조회 API.
|
||||
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공.
|
||||
* 기존 iran proxy와 별도 경로 (/api/analysis/*).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/analysis")
|
||||
@RequiredArgsConstructor
|
||||
public class VesselAnalysisController {
|
||||
|
||||
private final VesselAnalysisService service;
|
||||
|
||||
/**
|
||||
* 분석 결과 목록 조회 (필터 + 페이징).
|
||||
* 기본: 최근 1시간 내 결과.
|
||||
*/
|
||||
@GetMapping("/vessels")
|
||||
@RequirePermission(resource = "detection", operation = "READ")
|
||||
public Page<VesselAnalysisResponse> listVessels(
|
||||
@RequestParam(required = false) String mmsi,
|
||||
@RequestParam(required = false) String zoneCode,
|
||||
@RequestParam(required = false) String riskLevel,
|
||||
@RequestParam(required = false) Boolean isDark,
|
||||
@RequestParam(defaultValue = "1") int hours,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size
|
||||
) {
|
||||
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
|
||||
return service.getAnalysisResults(
|
||||
mmsi, zoneCode, riskLevel, isDark, after,
|
||||
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt"))
|
||||
).map(VesselAnalysisResponse::from);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 선박 최신 분석 결과 (features 포함).
|
||||
*/
|
||||
@GetMapping("/vessels/{mmsi}")
|
||||
@RequirePermission(resource = "detection", operation = "READ")
|
||||
public VesselAnalysisResponse getLatest(@PathVariable String mmsi) {
|
||||
return VesselAnalysisResponse.from(service.getLatestByMmsi(mmsi));
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 선박 분석 이력 (기본 24시간).
|
||||
*/
|
||||
@GetMapping("/vessels/{mmsi}/history")
|
||||
@RequirePermission(resource = "detection", operation = "READ")
|
||||
public List<VesselAnalysisResponse> getHistory(
|
||||
@PathVariable String mmsi,
|
||||
@RequestParam(defaultValue = "24") int hours
|
||||
) {
|
||||
return service.getHistory(mmsi, hours).stream()
|
||||
.map(VesselAnalysisResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 다크 베셀 목록 (최신 분석, MMSI 중복 제거).
|
||||
*/
|
||||
@GetMapping("/dark")
|
||||
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
|
||||
public Page<VesselAnalysisResponse> listDarkVessels(
|
||||
@RequestParam(defaultValue = "1") int hours,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size
|
||||
) {
|
||||
return service.getDarkVessels(hours,
|
||||
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "riskScore"))
|
||||
).map(VesselAnalysisResponse::from);
|
||||
}
|
||||
|
||||
/**
|
||||
* 환적 의심 목록 (최신 분석, MMSI 중복 제거).
|
||||
*/
|
||||
@GetMapping("/transship")
|
||||
@RequirePermission(resource = "detection", operation = "READ")
|
||||
public Page<VesselAnalysisResponse> listTransshipSuspects(
|
||||
@RequestParam(defaultValue = "1") int hours,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size
|
||||
) {
|
||||
return service.getTransshipSuspects(hours,
|
||||
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "riskScore"))
|
||||
).map(VesselAnalysisResponse::from);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
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 org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* vessel_analysis_results 읽기 전용 Repository.
|
||||
*/
|
||||
public interface VesselAnalysisRepository
|
||||
extends JpaRepository<VesselAnalysisResult, Long>, JpaSpecificationExecutor<VesselAnalysisResult> {
|
||||
|
||||
/**
|
||||
* 특정 선박의 최신 분석 결과.
|
||||
*/
|
||||
Optional<VesselAnalysisResult> findTopByMmsiOrderByAnalyzedAtDesc(String mmsi);
|
||||
|
||||
/**
|
||||
* 특정 선박의 분석 이력 (시간 범위).
|
||||
*/
|
||||
List<VesselAnalysisResult> findByMmsiAndAnalyzedAtAfterOrderByAnalyzedAtDesc(
|
||||
String mmsi, OffsetDateTime after);
|
||||
|
||||
/**
|
||||
* 다크 베셀 목록 (최근 분석 결과, MMSI 중복 제거).
|
||||
*/
|
||||
@Query("""
|
||||
SELECT v FROM VesselAnalysisResult v
|
||||
WHERE v.isDark = true AND v.analyzedAt > :after
|
||||
AND v.analyzedAt = (
|
||||
SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2
|
||||
WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after
|
||||
)
|
||||
ORDER BY v.riskScore DESC
|
||||
""")
|
||||
Page<VesselAnalysisResult> findLatestDarkVessels(
|
||||
@Param("after") OffsetDateTime after, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 환적 의심 목록 (최근 분석 결과, MMSI 중복 제거).
|
||||
*/
|
||||
@Query("""
|
||||
SELECT v FROM VesselAnalysisResult v
|
||||
WHERE v.transshipSuspect = true AND v.analyzedAt > :after
|
||||
AND v.analyzedAt = (
|
||||
SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2
|
||||
WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after
|
||||
)
|
||||
ORDER BY v.riskScore DESC
|
||||
""")
|
||||
Page<VesselAnalysisResult> findLatestTransshipSuspects(
|
||||
@Param("after") OffsetDateTime after, Pageable pageable);
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
package gc.mda.kcg.domain.analysis;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* vessel_analysis_results 응답 DTO.
|
||||
* 프론트엔드에서 필요한 핵심 필드만 포함.
|
||||
*/
|
||||
public record VesselAnalysisResponse(
|
||||
Long id,
|
||||
String mmsi,
|
||||
OffsetDateTime analyzedAt,
|
||||
// 분류
|
||||
String vesselType,
|
||||
BigDecimal confidence,
|
||||
BigDecimal fishingPct,
|
||||
String season,
|
||||
// 위치
|
||||
Double lat,
|
||||
Double lon,
|
||||
String zoneCode,
|
||||
BigDecimal distToBaselineNm,
|
||||
// 행동
|
||||
String activityState,
|
||||
// 위협
|
||||
Boolean isDark,
|
||||
Integer gapDurationMin,
|
||||
String darkPattern,
|
||||
BigDecimal spoofingScore,
|
||||
Integer speedJumpCount,
|
||||
// 환적
|
||||
Boolean transshipSuspect,
|
||||
String transshipPairMmsi,
|
||||
Integer transshipDurationMin,
|
||||
// 선단
|
||||
Integer fleetClusterId,
|
||||
String fleetRole,
|
||||
Boolean fleetIsLeader,
|
||||
// 위험도
|
||||
Integer riskScore,
|
||||
String riskLevel,
|
||||
// 확장
|
||||
String gearCode,
|
||||
String gearJudgment,
|
||||
String permitStatus,
|
||||
// features
|
||||
Map<String, Object> features
|
||||
) {
|
||||
public static VesselAnalysisResponse from(VesselAnalysisResult e) {
|
||||
return new VesselAnalysisResponse(
|
||||
e.getId(),
|
||||
e.getMmsi(),
|
||||
e.getAnalyzedAt(),
|
||||
e.getVesselType(),
|
||||
e.getConfidence(),
|
||||
e.getFishingPct(),
|
||||
e.getSeason(),
|
||||
e.getLat(),
|
||||
e.getLon(),
|
||||
e.getZoneCode(),
|
||||
e.getDistToBaselineNm(),
|
||||
e.getActivityState(),
|
||||
e.getIsDark(),
|
||||
e.getGapDurationMin(),
|
||||
e.getDarkPattern(),
|
||||
e.getSpoofingScore(),
|
||||
e.getSpeedJumpCount(),
|
||||
e.getTransshipSuspect(),
|
||||
e.getTransshipPairMmsi(),
|
||||
e.getTransshipDurationMin(),
|
||||
e.getFleetClusterId(),
|
||||
e.getFleetRole(),
|
||||
e.getFleetIsLeader(),
|
||||
e.getRiskScore(),
|
||||
e.getRiskLevel(),
|
||||
e.getGearCode(),
|
||||
e.getGearJudgment(),
|
||||
e.getPermitStatus(),
|
||||
e.getFeatures()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,135 @@
|
||||
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.Map;
|
||||
|
||||
/**
|
||||
* vessel_analysis_results 읽기 전용 Entity.
|
||||
* prediction 엔진이 5분 주기로 INSERT, 백엔드는 READ만 수행.
|
||||
*
|
||||
* DB PK는 (id, analyzed_at) 복합키(파티션)이지만,
|
||||
* BIGSERIAL id가 전역 유니크이므로 JPA에서는 id만 @Id로 매핑.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "vessel_analysis_results", schema = "kcg")
|
||||
@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
public class VesselAnalysisResult {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@Column(name = "mmsi", nullable = false, length = 20)
|
||||
private String mmsi;
|
||||
|
||||
@Column(name = "analyzed_at", nullable = false)
|
||||
private OffsetDateTime analyzedAt;
|
||||
|
||||
// 분류
|
||||
@Column(name = "vessel_type", length = 30)
|
||||
private String vesselType;
|
||||
|
||||
@Column(name = "confidence", precision = 5, scale = 4)
|
||||
private BigDecimal confidence;
|
||||
|
||||
@Column(name = "fishing_pct", precision = 5, scale = 4)
|
||||
private BigDecimal fishingPct;
|
||||
|
||||
@Column(name = "cluster_id")
|
||||
private Integer clusterId;
|
||||
|
||||
@Column(name = "season", length = 20)
|
||||
private String season;
|
||||
|
||||
// 위치
|
||||
@Column(name = "lat")
|
||||
private Double lat;
|
||||
|
||||
@Column(name = "lon")
|
||||
private Double lon;
|
||||
|
||||
@Column(name = "zone_code", length = 30)
|
||||
private String zoneCode;
|
||||
|
||||
@Column(name = "dist_to_baseline_nm", precision = 8, scale = 2)
|
||||
private BigDecimal distToBaselineNm;
|
||||
|
||||
// 행동 분석
|
||||
@Column(name = "activity_state", length = 20)
|
||||
private String activityState;
|
||||
|
||||
@Column(name = "ucaf_score", precision = 5, scale = 4)
|
||||
private BigDecimal ucafScore;
|
||||
|
||||
@Column(name = "ucft_score", precision = 5, scale = 4)
|
||||
private BigDecimal ucftScore;
|
||||
|
||||
// 위협 탐지
|
||||
@Column(name = "is_dark")
|
||||
private Boolean isDark;
|
||||
|
||||
@Column(name = "gap_duration_min")
|
||||
private Integer gapDurationMin;
|
||||
|
||||
@Column(name = "dark_pattern", length = 30)
|
||||
private String darkPattern;
|
||||
|
||||
@Column(name = "spoofing_score", precision = 5, scale = 4)
|
||||
private BigDecimal spoofingScore;
|
||||
|
||||
@Column(name = "bd09_offset_m", precision = 8, scale = 2)
|
||||
private BigDecimal bd09OffsetM;
|
||||
|
||||
@Column(name = "speed_jump_count")
|
||||
private Integer speedJumpCount;
|
||||
|
||||
// 환적
|
||||
@Column(name = "transship_suspect")
|
||||
private Boolean transshipSuspect;
|
||||
|
||||
@Column(name = "transship_pair_mmsi", length = 20)
|
||||
private String transshipPairMmsi;
|
||||
|
||||
@Column(name = "transship_duration_min")
|
||||
private Integer transshipDurationMin;
|
||||
|
||||
// 선단
|
||||
@Column(name = "fleet_cluster_id")
|
||||
private Integer fleetClusterId;
|
||||
|
||||
@Column(name = "fleet_role", length = 20)
|
||||
private String fleetRole;
|
||||
|
||||
@Column(name = "fleet_is_leader")
|
||||
private Boolean fleetIsLeader;
|
||||
|
||||
// 위험도
|
||||
@Column(name = "risk_score")
|
||||
private Integer riskScore;
|
||||
|
||||
@Column(name = "risk_level", length = 20)
|
||||
private String riskLevel;
|
||||
|
||||
// 확장
|
||||
@Column(name = "gear_code", length = 20)
|
||||
private String gearCode;
|
||||
|
||||
@Column(name = "gear_judgment", length = 30)
|
||||
private String gearJudgment;
|
||||
|
||||
@Column(name = "permit_status", length = 20)
|
||||
private String permitStatus;
|
||||
|
||||
// features JSONB
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "features", columnDefinition = "jsonb")
|
||||
private Map<String, Object> features;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private OffsetDateTime createdAt;
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
package gc.mda.kcg.domain.analysis;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* vessel_analysis_results 직접 조회 서비스.
|
||||
* prediction이 write한 분석 결과를 프론트엔드에 제공.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class VesselAnalysisService {
|
||||
|
||||
private final VesselAnalysisRepository repository;
|
||||
|
||||
/**
|
||||
* 분석 결과 목록 조회 (동적 필터).
|
||||
*/
|
||||
public Page<VesselAnalysisResult> getAnalysisResults(
|
||||
String mmsi, String zoneCode, String riskLevel, Boolean isDark,
|
||||
OffsetDateTime after, Pageable pageable
|
||||
) {
|
||||
Specification<VesselAnalysisResult> spec = Specification.where(null);
|
||||
|
||||
if (after != null) {
|
||||
spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("analyzedAt"), after));
|
||||
}
|
||||
if (mmsi != null && !mmsi.isBlank()) {
|
||||
spec = spec.and((root, query, cb) -> cb.equal(root.get("mmsi"), mmsi));
|
||||
}
|
||||
if (zoneCode != null && !zoneCode.isBlank()) {
|
||||
spec = spec.and((root, query, cb) -> cb.equal(root.get("zoneCode"), zoneCode));
|
||||
}
|
||||
if (riskLevel != null && !riskLevel.isBlank()) {
|
||||
spec = spec.and((root, query, cb) -> cb.equal(root.get("riskLevel"), riskLevel));
|
||||
}
|
||||
if (isDark != null && isDark) {
|
||||
spec = spec.and((root, query, cb) -> cb.isTrue(root.get("isDark")));
|
||||
}
|
||||
|
||||
return repository.findAll(spec, pageable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 선박 최신 분석 결과.
|
||||
*/
|
||||
public VesselAnalysisResult getLatestByMmsi(String mmsi) {
|
||||
return repository.findTopByMmsiOrderByAnalyzedAtDesc(mmsi)
|
||||
.orElseThrow(() -> new IllegalArgumentException("ANALYSIS_NOT_FOUND: " + mmsi));
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 선박 분석 이력 (시간 범위).
|
||||
*/
|
||||
public List<VesselAnalysisResult> getHistory(String mmsi, int hours) {
|
||||
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
|
||||
return repository.findByMmsiAndAnalyzedAtAfterOrderByAnalyzedAtDesc(mmsi, after);
|
||||
}
|
||||
|
||||
/**
|
||||
* 다크 베셀 목록 (최신 분석, MMSI 중복 제거).
|
||||
*/
|
||||
public Page<VesselAnalysisResult> getDarkVessels(int hours, Pageable pageable) {
|
||||
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
|
||||
return repository.findLatestDarkVessels(after, pageable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 환적 의심 목록 (최신 분석, MMSI 중복 제거).
|
||||
*/
|
||||
public Page<VesselAnalysisResult> getTransshipSuspects(int hours, Pageable pageable) {
|
||||
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
|
||||
return repository.findLatestTransshipSuspects(after, pageable);
|
||||
}
|
||||
}
|
||||
@ -32,9 +32,10 @@ public class EnforcementController {
|
||||
@RequirePermission(resource = "enforcement:enforcement-history", operation = "READ")
|
||||
public Page<EnforcementRecord> listRecords(
|
||||
@RequestParam(required = false) String violationType,
|
||||
@RequestParam(required = false) String vesselMmsi,
|
||||
Pageable pageable
|
||||
) {
|
||||
return service.listRecords(violationType, pageable);
|
||||
return service.listRecords(violationType, vesselMmsi, pageable);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -32,7 +32,10 @@ public class EnforcementService {
|
||||
// 단속 이력
|
||||
// ========================================================================
|
||||
|
||||
public Page<EnforcementRecord> listRecords(String violationType, Pageable pageable) {
|
||||
public Page<EnforcementRecord> listRecords(String violationType, String vesselMmsi, Pageable pageable) {
|
||||
if (vesselMmsi != null && !vesselMmsi.isBlank()) {
|
||||
return recordRepository.findByVesselMmsiOrderByEnforcedAtDesc(vesselMmsi, pageable);
|
||||
}
|
||||
if (violationType != null && !violationType.isBlank()) {
|
||||
return recordRepository.findByViolationType(violationType, pageable);
|
||||
}
|
||||
|
||||
@ -8,4 +8,5 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
public interface EnforcementRecordRepository extends JpaRepository<EnforcementRecord, Long> {
|
||||
Page<EnforcementRecord> findAllByOrderByEnforcedAtDesc(Pageable pageable);
|
||||
Page<EnforcementRecord> findByViolationType(String violationType, Pageable pageable);
|
||||
Page<EnforcementRecord> findByVesselMmsiOrderByEnforcedAtDesc(String vesselMmsi, Pageable pageable);
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
@ -93,6 +94,10 @@ public class PredictionEvent {
|
||||
@Column(name = "dedup_key", length = 200)
|
||||
private String dedupKey;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "features", columnDefinition = "jsonb")
|
||||
private Map<String, Object> features;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
-- ============================================================
|
||||
-- V018: prediction_events에 features JSONB 컬럼 추가
|
||||
-- event_generator가 분석 결과의 핵심 특성(dark_tier, transship_score 등)을
|
||||
-- 이벤트와 함께 저장하여 프론트엔드에서 직접 활용할 수 있도록 한다.
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE kcg.prediction_events
|
||||
ADD COLUMN IF NOT EXISTS features JSONB;
|
||||
|
||||
COMMENT ON COLUMN kcg.prediction_events.features IS
|
||||
'분석 결과 핵심 특성 (dark_tier, dark_suspicion_score, transship_tier, transship_score 등)';
|
||||
@ -6,6 +6,7 @@
|
||||
dedup: 동일 mmsi + category + 윈도우 내 중복 방지.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
@ -214,6 +215,10 @@ def run_event_generator(analysis_results: list[dict]) -> dict:
|
||||
event_uid = _make_event_uid(now, seq)
|
||||
seq += 1
|
||||
|
||||
# features 추출: 이벤트에 연관된 핵심 특성만 저장
|
||||
raw_features = result.get('features')
|
||||
features_json = json.dumps(raw_features, ensure_ascii=False) if raw_features else None
|
||||
|
||||
events_to_insert.append((
|
||||
event_uid,
|
||||
now, # occurred_at
|
||||
@ -233,6 +238,7 @@ def run_event_generator(analysis_results: list[dict]) -> dict:
|
||||
result.get('confidence') or result.get('risk_score', 0) / 100.0,
|
||||
'NEW', # status
|
||||
dedup_key,
|
||||
features_json,
|
||||
))
|
||||
generated += 1
|
||||
# break 제거: 한 분석결과가 여러 룰에 매칭되면 모두 생성
|
||||
@ -244,7 +250,7 @@ def run_event_generator(analysis_results: list[dict]) -> dict:
|
||||
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)
|
||||
source_type, source_ref_id, ai_confidence, status, dedup_key, features)
|
||||
VALUES %s
|
||||
ON CONFLICT (event_uid) DO NOTHING""",
|
||||
events_to_insert,
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user