Codex Lab 환경(iran-airstrike-replay-codex)에서 검증 완료된 어구 모선 자동 추론 + 검토 워크플로우 전체를 이식. ## Python (prediction/) - gear_parent_inference(1,428줄): 다층 점수 모델 (correlation + name + track + prior bonus) - gear_parent_episode(631줄): Episode 연속성 (Jaccard + 공간거리) - gear_name_rules: 모선 이름 정규화 + 4자 미만 필터 - scheduler: 추론 호출 단계 추가 (4.8) - fleet_tracker/kcgdb: SQL qualified_table() 동적화 - gear_correlation: timestamp 필드 추가 ## DB (database/migration/ 012~015) - 후보 스냅샷, resolution, episode, 라벨 세션, 제외 관리 테이블 9개 + VIEW 2개 ## Backend (Java) - 12개 DTO/Controller (ParentInferenceWorkflowController 등) - GroupPolygonService: parent_resolution LEFT JOIN + 15개 API 메서드 ## Frontend - ParentReviewPanel: 모선 검토 대시보드 - vesselAnalysis: 10개 신규 API 함수 + 6개 타입 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
16 KiB
Gear Parent Inference Workflow V2 Phase 1 Spec
목적
이 문서는 GEAR-PARENT-INFERENCE-WORKFLOW-V2.md의 첫 구현 단계를 바로 개발할 수 있는 수준으로 구체화한 명세다.
Phase 1 범위는 아래로 제한한다.
- DB 마이그레이션
- backend API 계약
- prediction exclusion/label read-write 지점
- 프론트의 최소 계약 변화
이번 단계에서는 실제 자동화/LLM 연결은 다루지 않는다.
범위 요약
포함
- 그룹 단위 후보 제외
1/3/5일 - 전역 후보 제외
- 정답 라벨 세션
1/3/5일 - 라벨 세션 기간 동안 cycle별 tracking 기록
- active exclusion을 parent inference 후보 생성에 반영
- exclusion/label 관리 API
제외
- 운영
kcg스키마 반영 - 기존
gear_correlation_scores산식 변경 - LLM reviewer
- label session의 anchor 기반 재매칭 보강
- UI 고도화 화면 전부
구현 원칙
- 기존 자동 추론 저장소는 유지한다.
- 새 사람 판단 데이터는 별도 테이블에 저장한다.
- Phase 1에서는
group_key + sub_cluster_id를 세션 식별 기준으로 고정한다. - 기존
CONFIRM/REJECT/RESETAPI는 삭제하지 않지만, 새 UI에서는 사용하지 않는다. - 새 API와 prediction 로직은
kcg_lab기준으로만 먼저 구현한다.
DB 명세
1. gear_parent_candidate_exclusions
목적
- 그룹 단위 후보 제외와 전역 후보 제외를 단일 저장소에서 관리
DDL 초안
CREATE TABLE IF NOT EXISTS kcg.gear_parent_candidate_exclusions (
id BIGSERIAL PRIMARY KEY,
scope_type VARCHAR(16) NOT NULL,
group_key VARCHAR(100),
sub_cluster_id SMALLINT,
candidate_mmsi VARCHAR(20) NOT NULL,
reason_type VARCHAR(32) NOT NULL,
duration_days INT,
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
active_until TIMESTAMPTZ,
released_at TIMESTAMPTZ,
released_by VARCHAR(100),
actor VARCHAR(100) NOT NULL,
comment TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_gpce_scope CHECK (scope_type IN ('GROUP', 'GLOBAL')),
CONSTRAINT chk_gpce_reason CHECK (reason_type IN ('GROUP_WRONG_PARENT', 'GLOBAL_NOT_PARENT_TARGET')),
CONSTRAINT chk_gpce_group_scope CHECK (
(scope_type = 'GROUP' AND group_key IS NOT NULL AND sub_cluster_id IS NOT NULL AND duration_days IN (1, 3, 5) AND active_until IS NOT NULL)
OR
(scope_type = 'GLOBAL' AND duration_days IS NULL)
)
);
인덱스
CREATE INDEX IF NOT EXISTS idx_gpce_scope_mmsi_active
ON kcg.gear_parent_candidate_exclusions(scope_type, candidate_mmsi, active_from DESC)
WHERE released_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_gpce_group_active
ON kcg.gear_parent_candidate_exclusions(group_key, sub_cluster_id, active_from DESC)
WHERE released_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_gpce_active_until
ON kcg.gear_parent_candidate_exclusions(active_until);
active 판정 규칙
active exclusion은 아래를 만족해야 한다.
released_at IS NULL
AND active_from <= NOW()
AND (active_until IS NULL OR active_until > NOW())
해석 규칙
GROUP- 특정 그룹에서만 해당 후보 제거
GLOBAL- 모든 그룹에서 해당 후보 제거
2. gear_parent_label_sessions
목적
- 정답 라벨 세션 저장
DDL 초안
CREATE TABLE IF NOT EXISTS kcg.gear_parent_label_sessions (
id BIGSERIAL PRIMARY KEY,
group_key VARCHAR(100) NOT NULL,
sub_cluster_id SMALLINT NOT NULL,
label_parent_mmsi VARCHAR(20) NOT NULL,
label_parent_name VARCHAR(200),
label_parent_vessel_id INT REFERENCES kcg.fleet_vessels(id) ON DELETE SET NULL,
duration_days INT NOT NULL,
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
active_until TIMESTAMPTZ NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
actor VARCHAR(100) NOT NULL,
comment TEXT,
anchor_snapshot_time TIMESTAMPTZ,
anchor_center_point geometry(Point, 4326),
anchor_member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_gpls_duration CHECK (duration_days IN (1, 3, 5)),
CONSTRAINT chk_gpls_status CHECK (status IN ('ACTIVE', 'EXPIRED', 'CANCELLED'))
);
인덱스
CREATE INDEX IF NOT EXISTS idx_gpls_group_active
ON kcg.gear_parent_label_sessions(group_key, sub_cluster_id, active_from DESC)
WHERE status = 'ACTIVE';
CREATE INDEX IF NOT EXISTS idx_gpls_mmsi_active
ON kcg.gear_parent_label_sessions(label_parent_mmsi, active_from DESC)
WHERE status = 'ACTIVE';
CREATE INDEX IF NOT EXISTS idx_gpls_active_until
ON kcg.gear_parent_label_sessions(active_until);
active 판정 규칙
status = 'ACTIVE'
AND active_from <= NOW()
AND active_until > NOW()
만료 처리 규칙
prediction 또는 backend batch에서 아래를 주기적으로 실행한다.
UPDATE kcg.gear_parent_label_sessions
SET status = 'EXPIRED', updated_at = NOW()
WHERE status = 'ACTIVE'
AND active_until <= NOW();
3. gear_parent_label_tracking_cycles
목적
- 활성 정답 라벨 세션 동안 cycle별 자동 추론 결과 저장
DDL 초안
CREATE TABLE IF NOT EXISTS kcg.gear_parent_label_tracking_cycles (
id BIGSERIAL PRIMARY KEY,
label_session_id BIGINT NOT NULL REFERENCES kcg.gear_parent_label_sessions(id) ON DELETE CASCADE,
observed_at TIMESTAMPTZ NOT NULL,
candidate_snapshot_observed_at TIMESTAMPTZ,
auto_status VARCHAR(40),
top_candidate_mmsi VARCHAR(20),
top_candidate_name VARCHAR(200),
top_candidate_score DOUBLE PRECISION,
top_candidate_margin DOUBLE PRECISION,
candidate_count INT NOT NULL DEFAULT 0,
labeled_candidate_present BOOLEAN NOT NULL DEFAULT FALSE,
labeled_candidate_rank INT,
labeled_candidate_score DOUBLE PRECISION,
labeled_candidate_pre_bonus_score DOUBLE PRECISION,
labeled_candidate_margin_from_top DOUBLE PRECISION,
matched_top1 BOOLEAN NOT NULL DEFAULT FALSE,
matched_top3 BOOLEAN NOT NULL DEFAULT FALSE,
evidence_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_gpltc_session_observed UNIQUE (label_session_id, observed_at)
);
인덱스
CREATE INDEX IF NOT EXISTS idx_gpltc_session_observed
ON kcg.gear_parent_label_tracking_cycles(label_session_id, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_gpltc_top_candidate
ON kcg.gear_parent_label_tracking_cycles(top_candidate_mmsi);
4. 기존 gear_group_parent_review_log action 확장
새 action 목록
LABEL_PARENTEXCLUDE_GROUPEXCLUDE_GLOBALRELEASE_EXCLUSIONCANCEL_LABEL
기존 action과 공존한다.
migration 파일 제안
014_gear_parent_workflow_v2_phase1.sql
구성 순서:
- 새 테이블 3개 생성
- 인덱스 생성
- review log action 확장은 schema 변경 불필요
- optional helper view 추가
optional view 제안
vw_active_gear_parent_candidate_exclusions
CREATE OR REPLACE VIEW kcg.vw_active_gear_parent_candidate_exclusions AS
SELECT *
FROM kcg.gear_parent_candidate_exclusions
WHERE released_at IS NULL
AND active_from <= NOW()
AND (active_until IS NULL OR active_until > NOW());
vw_active_gear_parent_label_sessions
CREATE OR REPLACE VIEW kcg.vw_active_gear_parent_label_sessions AS
SELECT *
FROM kcg.gear_parent_label_sessions
WHERE status = 'ACTIVE'
AND active_from <= NOW()
AND active_until > NOW();
backend API 명세
공통 정책
- 모든 write API는
actor필수 group_key,sub_cluster_id,candidate_mmsi,selected_parent_mmsi는 trim 후 저장- 잘못된 기간은
400 Bad Request - 중복 active session/exclusion 생성 시
409 Conflict대신 동일 active row를 반환해도 됨 - Phase 1에서는 멱등성을 우선한다
1. 정답 라벨 세션 생성
endpoint
POST /api/vessel-analysis/groups/{groupKey}/parent-inference/{subClusterId}/label-sessions
request
{
"selectedParentMmsi": "412333326",
"durationDays": 3,
"actor": "analyst-01",
"comment": "수동 검토 확정"
}
validation
selectedParentMmsi필수durationDays in (1,3,5)- 동일
groupKey + subClusterId에 active label session이 이미 있으면 새 row 생성 금지
response
{
"groupKey": "58399",
"subClusterId": 0,
"action": "LABEL_PARENT",
"labelSession": {
"id": 12,
"status": "ACTIVE",
"labelParentMmsi": "412333326",
"labelParentName": "UWEIJINGYU51015",
"durationDays": 3,
"activeFrom": "2026-04-03T10:00:00+09:00",
"activeUntil": "2026-04-06T10:00:00+09:00",
"actor": "analyst-01",
"comment": "수동 검토 확정"
}
}
2. 그룹 후보 제외 생성
endpoint
POST /api/vessel-analysis/groups/{groupKey}/parent-inference/{subClusterId}/candidate-exclusions
request
{
"candidateMmsi": "412333326",
"durationDays": 3,
"actor": "analyst-01",
"comment": "이 그룹에서는 오답"
}
생성 규칙
- 내부적으로
scopeType='GROUP' reasonType='GROUP_WRONG_PARENT'- 동일
groupKey + subClusterId + candidateMmsiactive row가 있으면 재사용
response
{
"groupKey": "58399",
"subClusterId": 0,
"action": "EXCLUDE_GROUP",
"exclusion": {
"id": 33,
"scopeType": "GROUP",
"candidateMmsi": "412333326",
"durationDays": 3,
"activeFrom": "2026-04-03T10:00:00+09:00",
"activeUntil": "2026-04-06T10:00:00+09:00"
}
}
3. 전역 후보 제외 생성
endpoint
POST /api/vessel-analysis/parent-inference/candidate-exclusions/global
request
{
"candidateMmsi": "412333326",
"actor": "analyst-01",
"comment": "모든 어구에서 후보 제외"
}
생성 규칙
scopeType='GLOBAL'reasonType='GLOBAL_NOT_PARENT_TARGET'activeUntil = NULL- 동일 candidate active global exclusion이 있으면 재사용
4. exclusion 해제
endpoint
POST /api/vessel-analysis/parent-inference/candidate-exclusions/{id}/release
request
{
"actor": "analyst-01",
"comment": "해제"
}
동작
released_at = NOW()released_by = actorupdated_at = NOW()
5. label session 종료
endpoint
POST /api/vessel-analysis/parent-inference/label-sessions/{id}/cancel
request
{
"actor": "analyst-01",
"comment": "조기 종료"
}
동작
status='CANCELLED'updated_at = NOW()
6. active exclusion 조회
endpoint
GET /api/vessel-analysis/parent-inference/candidate-exclusions?status=ACTIVE&scopeType=GROUP|GLOBAL&candidateMmsi=...&groupKey=...
response 필드
idscopeTypegroupKeysubClusterIdcandidateMmsireasonTypedurationDaysactiveFromactiveUntilreleasedAtactorcommentisActive
7. label session 목록
endpoint
GET /api/vessel-analysis/parent-inference/label-sessions?status=ACTIVE|EXPIRED|CANCELLED&groupKey=...
response 필드
idgroupKeysubClusterIdlabelParentMmsilabelParentNamedurationDaysactiveFromactiveUntilstatusactorcommentlatestTrackingSummary
8. label tracking 상세
endpoint
GET /api/vessel-analysis/parent-inference/label-sessions/{id}/tracking
response 필드
sessioncountitems[]observedAtautoStatustopCandidateMmsitopCandidateScoretopCandidateMargincandidateCountlabeledCandidatePresentlabeledCandidateRanklabeledCandidateScorelabeledCandidatePreBonusScorematchedTop1matchedTop3
backend 구현 위치
새 DTO/Request 제안
GroupParentLabelSessionRequestGroupParentCandidateExclusionRequestReleaseParentCandidateExclusionRequestCancelParentLabelSessionRequestParentCandidateExclusionDtoParentLabelSessionDtoParentLabelTrackingCycleDto
service 추가 메서드 제안
createGroupCandidateExclusion(...)createGlobalCandidateExclusion(...)releaseCandidateExclusion(...)createLabelSession(...)cancelLabelSession(...)listCandidateExclusions(...)listLabelSessions(...)getLabelSessionTracking(...)
prediction 명세
적용 함수
중심 파일은 prediction/algorithms/gear_parent_inference.py다.
새 load 함수
_load_active_candidate_exclusions(conn, group_keys)_load_active_label_sessions(conn, group_keys)
반환 구조
_load_active_candidate_exclusions
{
"global": {"412333326", "413000111"},
"group": {("58399", 0): {"412333326"}}
}
_load_active_label_sessions
{
("58399", 0): {
"id": 12,
"label_parent_mmsi": "412333326",
"active_until": ...,
...
}
}
후보 pruning 순서
- 기존 candidate union 생성
GLOBALexclusion 제거- 해당 그룹의
GROUPexclusion 제거 - 남은 후보만 scoring
tracking row write 규칙
각 그룹 처리 후:
- active label session이 없으면 skip
- 있으면 현재 cycle 결과를
gear_parent_label_tracking_cycles에 upsert-like insert
필수 기록값:
label_session_idobserved_atcandidate_snapshot_observed_atauto_statustop_candidate_mmsitop_candidate_scoretop_candidate_margincandidate_countlabeled_candidate_presentlabeled_candidate_ranklabeled_candidate_scorelabeled_candidate_pre_bonus_scorematched_top1matched_top3
pre-bonus score 취득
현재 candidate evidence에 이미 아래가 있다.
evidence.scoreBreakdown.preBonusScore
tracking row에서는 이 값을 직접 읽어 저장한다.
resolution 처리 원칙
Phase 1에서는 다음을 적용한다.
LABEL_PARENT,EXCLUDE_GROUP,EXCLUDE_GLOBAL은gear_group_parent_resolution상태를 바꾸지 않는다.- 자동 추론은 기존 상태 전이를 그대로 사용한다.
- legacy
MANUAL_CONFIRMED로직은 남겨두되, 새 UI에서는 호출하지 않는다.
프론트 최소 계약
기존 패널 액션 치환
현재:
확정24시간 제외
Phase 1 새 기본 액션:
정답 라벨이 그룹에서 제외전체 후보 제외
기간 선택 UI
정답 라벨:1일,3일,5일이 그룹에서 제외:1일,3일,5일전체 후보 제외: 기간 없음
표시 정보
후보 card badge:
이 그룹 제외 중전체 후보 제외 중정답 라벨 대상
그룹 summary box:
- active label session 여부
- active group exclusion count
API 에러 규약
400
- 잘못된 duration
- 필수 필드 누락
- groupKey/subClusterId 없음
404
- 대상 group 없음
- exclusion/session id 없음
409
- active label session 중복 생성
단, Phase 1에서는 backend에서 충돌 시 기존 active row를 그대로 반환하는 방식도 허용한다.
테스트 기준
DB
- GROUP exclusion active query가 정확히 동작
- GLOBAL exclusion active query가 정확히 동작
- label session 만료 시
EXPIRED전환
backend
- create/release exclusion API
- create/cancel label session API
- list APIs 필터 조건
prediction
- active exclusion candidate pruning
- global/group exclusion 우선 적용
- label session tracking row 생성
- labeled candidate absent/present/top1/top3 케이스
수용 기준
- 특정 그룹에서 후보 제외를 걸면 다음 cycle부터 그 그룹 후보 목록에서만 빠진다.
- 전역 후보 제외를 걸면 모든 그룹 후보 목록에서 빠진다.
- 정답 라벨 세션 생성 후 다음 cycle부터 tracking row가 쌓인다.
- 자동 resolution은 계속 자동 상태를 유지한다.
- 기존 manual override API를 쓰지 않아도 review/label/exclusion 흐름이 독립적으로 동작한다.
Phase 1 이후 바로 이어질 일
Phase 2
- 라벨 추적 대시보드
- exclusion 관리 화면
- 지표 요약 endpoint
- episode continuity read model 노출
- prior bonus calibration report
Phase 3
- label session anchor 기반 재매칭
- group case/episode lineage API 확장
- calibration report
권장 구현 순서
014_gear_parent_workflow_v2_phase1.sql- backend DTO + controller/service
- prediction active exclusion/load + tracking write
- frontend 버튼 교체와 최소 조회 화면
이 순서가 현재 코드 충돌과 운영 영향이 가장 적다.