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

707 lines
16 KiB
Markdown

# 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 초안
```sql
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)
)
);
```
### 인덱스
```sql
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은 아래를 만족해야 한다.
```sql
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 초안
```sql
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'))
);
```
### 인덱스
```sql
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 판정 규칙
```sql
status = 'ACTIVE'
AND active_from <= NOW()
AND active_until > NOW()
```
### 만료 처리 규칙
prediction 또는 backend batch에서 아래를 주기적으로 실행한다.
```sql
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 초안
```sql
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)
);
```
### 인덱스
```sql
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`
```sql
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`
```sql
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
```json
{
"selectedParentMmsi": "412333326",
"durationDays": 3,
"actor": "analyst-01",
"comment": "수동 검토 확정"
}
```
### validation
- `selectedParentMmsi` 필수
- `durationDays in (1,3,5)`
- 동일 `groupKey + subClusterId`에 active label session이 이미 있으면 새 row 생성 금지
### response
```json
{
"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
```json
{
"candidateMmsi": "412333326",
"durationDays": 3,
"actor": "analyst-01",
"comment": "이 그룹에서는 오답"
}
```
### 생성 규칙
- 내부적으로 `scopeType='GROUP'`
- `reasonType='GROUP_WRONG_PARENT'`
- 동일 `groupKey + subClusterId + candidateMmsi` active row가 있으면 재사용
### response
```json
{
"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
```json
{
"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
```json
{
"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
```json
{
"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](/Users/lht/work/devProjects/iran-airstrike-replay-codex/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`
```python
{
"global": {"412333326", "413000111"},
"group": {("58399", 0): {"412333326"}}
}
```
`_load_active_label_sessions`
```python
{
("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_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 케이스
## 수용 기준
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 버튼 교체와 최소 조회 화면
이 순서가 현재 코드 충돌과 운영 영향이 가장 적다.