# 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 버튼 교체와 최소 조회 화면 이 순서가 현재 코드 충돌과 운영 영향이 가장 적다.