kcg-monitoring/docs/GEAR-PARENT-INFERENCE-WORKFLOW-V2-PHASE1.md
htlee 7dd46f2078 feat: 어구 모선 추론(Gear Parent Inference) 시스템 이식
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>
2026-04-04 00:42:31 +09:00

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 고도화 화면 전부

구현 원칙

  1. 기존 자동 추론 저장소는 유지한다.
  2. 새 사람 판단 데이터는 별도 테이블에 저장한다.
  3. Phase 1에서는 group_key + sub_cluster_id를 세션 식별 기준으로 고정한다.
  4. 기존 CONFIRM/REJECT/RESET API는 삭제하지 않지만, 새 UI에서는 사용하지 않는다.
  5. 새 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_PARENT
  • EXCLUDE_GROUP
  • EXCLUDE_GLOBAL
  • RELEASE_EXCLUSION
  • CANCEL_LABEL

기존 action과 공존한다.

migration 파일 제안

  • 014_gear_parent_workflow_v2_phase1.sql

구성 순서:

  1. 새 테이블 3개 생성
  2. 인덱스 생성
  3. review log action 확장은 schema 변경 불필요
  4. 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 + candidateMmsi active 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 = actor
  • updated_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 필드

  • id
  • scopeType
  • groupKey
  • subClusterId
  • candidateMmsi
  • reasonType
  • durationDays
  • activeFrom
  • activeUntil
  • releasedAt
  • actor
  • comment
  • isActive

7. label session 목록

endpoint

GET /api/vessel-analysis/parent-inference/label-sessions?status=ACTIVE|EXPIRED|CANCELLED&groupKey=...

response 필드

  • id
  • groupKey
  • subClusterId
  • labelParentMmsi
  • labelParentName
  • durationDays
  • activeFrom
  • activeUntil
  • status
  • actor
  • comment
  • latestTrackingSummary

8. label tracking 상세

endpoint

GET /api/vessel-analysis/parent-inference/label-sessions/{id}/tracking

response 필드

  • session
  • count
  • items[]
    • observedAt
    • autoStatus
    • topCandidateMmsi
    • topCandidateScore
    • topCandidateMargin
    • candidateCount
    • labeledCandidatePresent
    • labeledCandidateRank
    • labeledCandidateScore
    • labeledCandidatePreBonusScore
    • matchedTop1
    • matchedTop3

backend 구현 위치

새 DTO/Request 제안

  • GroupParentLabelSessionRequest
  • GroupParentCandidateExclusionRequest
  • ReleaseParentCandidateExclusionRequest
  • CancelParentLabelSessionRequest
  • ParentCandidateExclusionDto
  • ParentLabelSessionDto
  • ParentLabelTrackingCycleDto

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 순서

  1. 기존 candidate union 생성
  2. GLOBAL exclusion 제거
  3. 해당 그룹의 GROUP exclusion 제거
  4. 남은 후보만 scoring

tracking row write 규칙

각 그룹 처리 후:

  • active label session이 없으면 skip
  • 있으면 현재 cycle 결과를 gear_parent_label_tracking_cycles에 upsert-like insert

필수 기록값:

  • label_session_id
  • observed_at
  • candidate_snapshot_observed_at
  • auto_status
  • top_candidate_mmsi
  • top_candidate_score
  • top_candidate_margin
  • candidate_count
  • labeled_candidate_present
  • labeled_candidate_rank
  • labeled_candidate_score
  • labeled_candidate_pre_bonus_score
  • matched_top1
  • matched_top3

pre-bonus score 취득

현재 candidate evidence에 이미 아래가 있다.

  • evidence.scoreBreakdown.preBonusScore

tracking row에서는 이 값을 직접 읽어 저장한다.

resolution 처리 원칙

Phase 1에서는 다음을 적용한다.

  • LABEL_PARENT, EXCLUDE_GROUP, EXCLUDE_GLOBALgear_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 케이스

수용 기준

  1. 특정 그룹에서 후보 제외를 걸면 다음 cycle부터 그 그룹 후보 목록에서만 빠진다.
  2. 전역 후보 제외를 걸면 모든 그룹 후보 목록에서 빠진다.
  3. 정답 라벨 세션 생성 후 다음 cycle부터 tracking row가 쌓인다.
  4. 자동 resolution은 계속 자동 상태를 유지한다.
  5. 기존 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

권장 구현 순서

  1. 014_gear_parent_workflow_v2_phase1.sql
  2. backend DTO + controller/service
  3. prediction active exclusion/load + tracking write
  4. frontend 버튼 교체와 최소 조회 화면

이 순서가 현재 코드 충돌과 운영 영향이 가장 적다.