From 0b74831b871fbaef38ff7d342810170d08ac4f9e Mon Sep 17 00:00:00 2001 From: htlee Date: Sat, 4 Apr 2026 10:21:28 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=AA=A8=EC=84=A0=20=EA=B2=80=ED=86=A0?= =?UTF-8?q?=20=EB=8C=80=EA=B8=B0=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20=ED=8F=B4?= =?UTF-8?q?=EB=A6=AC=EA=B3=A4=20=ED=8F=B4=EB=A7=81=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EC=97=90=EC=84=9C=20=ED=8C=8C=EC=83=9D=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: LATEST_GROUPS_SQL에 candidateCount CTE 추가 (GroupPolygonDto 확장) - Frontend: parentInferenceQueue를 별도 API 대신 groupPolygons useMemo 파생으로 전환 - 렌더 루프 수정: refreshParentInferenceQueue deps에서 groupPolygons → polygonRefresh 분리 - 초기 로드 시 자동 그룹 선택 제거, 검토 패널만 표시 - 후보 소스 배지 축약 (CORRELATION→CORR, PREVIOUS_SELECTION→PREV) - useGroupPolygons에 refresh 콜백 외부 노출 Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 4 + CLAUDE.md | 5 + .../mda/kcg/domain/fleet/GroupPolygonDto.java | 1 + .../kcg/domain/fleet/GroupPolygonService.java | 56 ++++++-- .../components/korea/FleetClusterLayer.tsx | 124 ++++++++---------- .../components/korea/ParentReviewPanel.tsx | 15 ++- frontend/src/hooks/useGroupPolygons.ts | 6 +- frontend/src/services/vesselAnalysis.ts | 1 + 8 files changed, 125 insertions(+), 87 deletions(-) diff --git a/.gitignore b/.gitignore index 49616a5..2f7fc5d 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,10 @@ coverage/ .prettiercache *.tsbuildinfo +# === Codex CLI === +AGENTS.md +.codex/ + # === Claude Code === # 글로벌 gitignore에서 .claude/ 전체를 무시하므로 팀 파일을 명시적으로 포함 !.claude/ diff --git a/CLAUDE.md b/CLAUDE.md index 6f311df..821fcbc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,6 +175,11 @@ deploy/ # systemd + nginx 배포 설정 3. **API 응답 검증**: MR 생성, 봇 승인, 머지 API 호출 후 반드시 상태를 확인. 실패 시 사용자에게 알리고 중단. +**MR/머지는 사용자 명시적 요청 없이 절대 진행 금지:** +- `git push` 완료 후에는 "푸시 완료" 보고만 하고 **중단** +- MR 생성은 사용자가 `/mr`, `/release` 호출 또는 "MR 해줘" 등 명시적 요청 시에만 +- **스킬 없이 Gitea API를 직접 호출하여 MR/머지를 진행하지 말 것** — 스킬 절차(사용자 확인 단계)를 우회하는 것과 동일 + ### 스킬 목록 - `/push` — 커밋 + 푸시 (해시 비교 → 커밋 메시지 확인 → 푸시) - `/mr` — 커밋 + 푸시 + MR 생성 (릴리즈 노트 갱신 포함) diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonDto.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonDto.java index ae3c03b..d416dc2 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonDto.java +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonDto.java @@ -26,5 +26,6 @@ public class GroupPolygonDto { private List> members; private String color; private String resolution; + private Integer candidateCount; private ParentInferenceSummaryDto parentInference; } diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java index db6b9bb..0daf126 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java @@ -36,10 +36,35 @@ public class GroupPolygonService { private volatile long lastCacheTime = 0; private static final String LATEST_GROUPS_SQL_TEMPLATE = """ - SELECT g.group_type, g.group_key, g.group_label, g.sub_cluster_id, g.snapshot_time, - ST_AsGeoJSON(g.polygon) AS polygon_geojson, - ST_Y(g.center_point) AS center_lat, ST_X(g.center_point) AS center_lon, - g.area_sq_nm, g.member_count, g.zone_id, g.zone_name, g.members, g.color, g.resolution, + WITH latest_groups AS ( + SELECT g.group_type, g.group_key, g.group_label, g.sub_cluster_id, g.snapshot_time, + ST_AsGeoJSON(g.polygon) AS polygon_geojson, + ST_Y(g.center_point) AS center_lat, ST_X(g.center_point) AS center_lon, + g.area_sq_nm, g.member_count, g.zone_id, g.zone_name, g.members, g.color, g.resolution + FROM %s g + WHERE g.snapshot_time = (SELECT MAX(snapshot_time) FROM %s WHERE resolution = '1h') + AND g.resolution = '1h' + ), + latest_candidate_snapshot AS ( + SELECT c.group_key, c.sub_cluster_id, MAX(c.observed_at) AS observed_at + FROM %s c + JOIN latest_groups lg + ON lg.group_key = c.group_key + AND lg.sub_cluster_id = c.sub_cluster_id + AND c.observed_at >= lg.snapshot_time + GROUP BY c.group_key, c.sub_cluster_id + ), + candidate_counts AS ( + SELECT c.group_key, c.sub_cluster_id, COUNT(*) AS candidate_count + FROM %s c + JOIN latest_candidate_snapshot l + ON l.group_key = c.group_key + AND l.sub_cluster_id = c.sub_cluster_id + AND l.observed_at = c.observed_at + GROUP BY c.group_key, c.sub_cluster_id + ) + SELECT lg.*, + COALESCE(cc.candidate_count, 0) AS candidate_count, r.normalized_parent_name, r.status AS parent_inference_status, r.selected_parent_mmsi, @@ -50,14 +75,15 @@ public class GroupPolygonService { r.score_margin, r.stable_cycles, r.evidence_summary - FROM %s g + FROM latest_groups lg LEFT JOIN %s r - ON r.group_key = g.group_key - AND r.sub_cluster_id = g.sub_cluster_id - AND r.last_evaluated_at >= g.snapshot_time - WHERE g.snapshot_time = (SELECT MAX(snapshot_time) FROM %s WHERE resolution = '1h') - AND g.resolution = '1h' - ORDER BY g.group_type, g.member_count DESC + ON r.group_key = lg.group_key + AND r.sub_cluster_id = lg.sub_cluster_id + AND r.last_evaluated_at >= lg.snapshot_time + LEFT JOIN candidate_counts cc + ON cc.group_key = lg.group_key + AND cc.sub_cluster_id = lg.sub_cluster_id + ORDER BY lg.group_type, lg.member_count DESC """; private static final String GROUP_DETAIL_SQL_TEMPLATE = """ @@ -330,10 +356,13 @@ public class GroupPolygonService { private String latestGroupsSql() { String groupPolygonSnapshots = table("group_polygon_snapshots"); + String candidateSnapshots = table("gear_group_parent_candidate_snapshots"); return LATEST_GROUPS_SQL_TEMPLATE.formatted( groupPolygonSnapshots, - table("gear_group_parent_resolution"), - groupPolygonSnapshots + groupPolygonSnapshots, + candidateSnapshots, + candidateSnapshots, + table("gear_group_parent_resolution") ); } @@ -1380,6 +1409,7 @@ public class GroupPolygonService { .members(members) .color(rs.getString("color")) .resolution(rs.getString("resolution")) + .candidateCount(nullableInt(rs, "candidate_count")) .parentInference(mapParentInferenceSummary(rs)) .build(); } diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index c3eea49..960db92 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -12,7 +12,6 @@ import { fetchGroupParentInference, fetchParentCandidateExclusions, fetchParentLabelSessions, - fetchParentInferenceReview, createGroupParentLabelSession, createGroupCandidateExclusion, createGlobalCandidateExclusion, @@ -134,6 +133,21 @@ function splitAndMergeHistory(history: GroupPolygonDto[]) { return { frames: fillGapFrames(frames as HistoryFrame[]), subClusterCenters }; } +function polygonToReviewItem(g: GroupPolygonDto): GroupParentInferenceItem { + return { + groupType: g.groupType, + groupKey: g.groupKey, + groupLabel: g.groupLabel, + subClusterId: g.subClusterId, + snapshotTime: g.snapshotTime, + zoneName: g.zoneName, + memberCount: g.memberCount, + resolution: g.resolution, + candidateCount: g.candidateCount ?? null, + parentInference: g.parentInference ?? null, + }; +} + function compareReviewQueueItems( a: GroupParentInferenceItem, b: GroupParentInferenceItem, @@ -331,7 +345,6 @@ export function FleetClusterLayer({ const [enabledModels, setEnabledModels] = useState>(new Set(['identity', 'default', 'aggressive', 'conservative', 'proximity-heavy', 'visit-pattern'])); const [hoveredTarget, setHoveredTarget] = useState<{ mmsi: string; model: string } | null>(null); const [parentInferenceItems, setParentInferenceItems] = useState([]); - const [parentInferenceQueue, setParentInferenceQueue] = useState([]); const [parentActiveGroupExclusions, setParentActiveGroupExclusions] = useState([]); const [parentActiveGlobalExclusions, setParentActiveGlobalExclusions] = useState([]); const [parentActiveLabelSessions, setParentActiveLabelSessions] = useState([]); @@ -468,21 +481,10 @@ export function FleetClusterLayer({ reviewSpatialFilterDrawing, ]); - const reloadParentInference = useCallback(async ( - groupKey: string, - options?: { reloadQueue?: boolean }, - ) => { + const reloadParentInference = useCallback(async (groupKey: string) => { setParentInferenceLoading(true); try { - const requests: Promise[] = []; - if (options?.reloadQueue !== false) { - requests.push( - fetchParentInferenceReview('REVIEW_REQUIRED', 100).catch( - () => ({ count: 0, items: [] as GroupParentInferenceItem[] }), - ), - ); - } - requests.push( + const [detailRes, groupExclusionRes, globalExclusionRes, labelSessionRes] = await Promise.all([ fetchGroupParentInference(groupKey).catch(() => ({ groupKey, count: 0, items: [] as GroupParentInferenceItem[] })), fetchParentCandidateExclusions({ groupKey, activeOnly: true, limit: 200 }).catch( () => ({ count: 0, items: [] as ParentCandidateExclusion[] }), @@ -493,20 +495,7 @@ export function FleetClusterLayer({ fetchParentLabelSessions({ groupKey, activeOnly: true, limit: 100 }).catch( () => ({ count: 0, items: [] as ParentLabelSession[] }), ), - ); - - const results = await Promise.all(requests); - let resultIndex = 0; - if (options?.reloadQueue !== false) { - const queueRes = results[resultIndex] as { count: number; items: GroupParentInferenceItem[] }; - setParentInferenceQueue(queueRes.items); - resultIndex += 1; - } - const detailRes = results[resultIndex++] as { groupKey: string; count: number; items: GroupParentInferenceItem[] }; - const groupExclusionRes = results[resultIndex++] as { count: number; items: ParentCandidateExclusion[] }; - const globalExclusionRes = results[resultIndex++] as { count: number; items: ParentCandidateExclusion[] }; - const labelSessionRes = results[resultIndex] as { count: number; items: ParentLabelSession[] }; - + ]); setParentInferenceItems(detailRes.items); setParentActiveGroupExclusions(groupExclusionRes.items); setParentActiveGlobalExclusions(globalExclusionRes.items); @@ -516,33 +505,13 @@ export function FleetClusterLayer({ } }, []); + const polygonRefresh = groupPolygons?.refresh; const refreshParentInferenceQueue = useCallback(async () => { - setParentInferenceLoading(true); - try { - const queueRes = await fetchParentInferenceReview('REVIEW_REQUIRED', 100).catch( - () => ({ count: 0, items: [] as GroupParentInferenceItem[] }), - ); - setParentInferenceQueue(queueRes.items); - if (selectedGearGroup) { - const [groupExclusionRes, globalExclusionRes, labelSessionRes] = await Promise.all([ - fetchParentCandidateExclusions({ groupKey: selectedGearGroup, activeOnly: true, limit: 200 }).catch( - () => ({ count: 0, items: [] as ParentCandidateExclusion[] }), - ), - fetchParentCandidateExclusions({ scopeType: 'GLOBAL', activeOnly: true, limit: 500 }).catch( - () => ({ count: 0, items: [] as ParentCandidateExclusion[] }), - ), - fetchParentLabelSessions({ groupKey: selectedGearGroup, activeOnly: true, limit: 100 }).catch( - () => ({ count: 0, items: [] as ParentLabelSession[] }), - ), - ]); - setParentActiveGroupExclusions(groupExclusionRes.items); - setParentActiveGlobalExclusions(globalExclusionRes.items); - setParentActiveLabelSessions(labelSessionRes.items); - } - } finally { - setParentInferenceLoading(false); + await polygonRefresh?.(); + if (selectedGearGroup) { + await reloadParentInference(selectedGearGroup); } - }, [selectedGearGroup]); + }, [polygonRefresh, selectedGearGroup, reloadParentInference]); // ── 히스토리 로드 (3개 API 순차 await → 스토어 초기화 → 재생) ── const loadHistory = async (groupKey: string) => { @@ -705,6 +674,23 @@ export function FleetClusterLayer({ return { byCompositeKey, byGroupKey }; }, [groupPolygons?.allGroups]); + const parentInferenceQueue = useMemo(() => { + const allGroups = groupPolygons?.allGroups ?? []; + return allGroups + .filter(g => + g.groupType !== 'FLEET' + && g.parentInference != null + && g.parentInference.status === 'REVIEW_REQUIRED', + ) + .map(polygonToReviewItem) + .sort((a, b) => { + const sa = a.parentInference?.topScore ?? 0; + const sb = b.parentInference?.topScore ?? 0; + if (sb !== sa) return sb - sa; + return (b.memberCount ?? 0) - (a.memberCount ?? 0); + }); + }, [groupPolygons?.allGroups]); + const reviewSpatialFilterPoints = useMemo( () => reviewSpatialFilterVertices.map(vertex => vertex.coordinate), [reviewSpatialFilterVertices], @@ -883,6 +869,7 @@ export function FleetClusterLayer({ || reviewSearchText.trim().length > 0 || !!reviewSpatialFilterPolygon; const reviewAutoFocusMode = autoOpenReviewPanel && !historyActive; + const showReviewPanel = !!selectedGearGroup || (reviewAutoFocusMode && parentInferenceQueue.length > 0); const effectiveEnabledModels = useMemo( () => (reviewAutoFocusMode ? new Set() : reviewDrivenOverlayMode ? new Set(['identity']) : enabledModels), [enabledModels, reviewAutoFocusMode, reviewDrivenOverlayMode], @@ -959,10 +946,7 @@ export function FleetClusterLayer({ return () => { cancelled = true; }; }, [selectedGearGroup, historyActive]); - useEffect(() => { - if (!autoOpenReviewPanel) return; - void refreshParentInferenceQueue(); - }, [autoOpenReviewPanel, refreshParentInferenceQueue]); + // parentInferenceQueue는 groupPolygons에서 파생되므로 별도 초기 로드 불필요 useEffect(() => { if (!selectedGearGroup) { @@ -974,7 +958,7 @@ export function FleetClusterLayer({ return; } let cancelled = false; - void reloadParentInference(selectedGearGroup, { reloadQueue: false }).catch(() => { + void reloadParentInference(selectedGearGroup).catch(() => { if (!cancelled) { setParentInferenceItems([]); setParentActiveGroupExclusions([]); @@ -1306,6 +1290,7 @@ export function FleetClusterLayer({ throw new Error('unsupported parent workflow action'); } await reloadParentInference(selectedGearGroup); + void polygonRefresh?.(); } catch (error) { const message = error instanceof Error ? error.message : 'parent workflow action failed'; console.warn('[parent-workflow] 실패:', message); @@ -1317,6 +1302,7 @@ export function FleetClusterLayer({ activeGlobalExclusionByMmsi, activeGroupExclusionByKey, activeLabelSessionByQueueKey, + polygonRefresh, parentReviewActor, parentWorkflowDurationDays, reloadParentInference, @@ -1333,18 +1319,12 @@ export function FleetClusterLayer({ handleGearGroupZoom(groupKey, subClusterId); }, [handleGearGroupZoom, reloadParentInference, selectedGearGroup]); + // autoOpenReviewPanel 시 검토 패널만 표시, 그룹 자동 선택 없음 useEffect(() => { - if (!autoOpenReviewPanel || autoOpenedReviewRef.current || selectedGearGroup || displayedParentInferenceQueue.length === 0) { - return; - } - const first = displayedParentInferenceQueue[0]; + if (!autoOpenReviewPanel || autoOpenedReviewRef.current) return; + if (displayedParentInferenceQueue.length === 0) return; autoOpenedReviewRef.current = true; - setSelectedReviewQueueKey(reviewItemKey(first.groupKey, first.subClusterId)); - setSelectedGearGroup(first.groupKey); - setExpandedGearGroup(first.groupKey); - const isInZone = groupPolygons?.gearInZoneGroups.some(g => g.groupKey === first.groupKey); - setActiveSection(isInZone ? 'inZone' : 'outZone'); - }, [autoOpenReviewPanel, displayedParentInferenceQueue, groupPolygons?.gearInZoneGroups, selectedGearGroup]); + }, [autoOpenReviewPanel, displayedParentInferenceQueue]); const resetParentReviewFilters = useCallback(() => { setReviewFiltersTouched(false); @@ -1418,9 +1398,9 @@ export function FleetClusterLayer({ /> )} - {selectedGearGroup && ( + {showReviewPanel && ( setStoredParentWorkflowDurationDays(sanitizeWorkflowDurationDays(value))} - onRefresh={() => { void reloadParentInference(selectedGearGroup); }} + onRefresh={() => { void refreshParentInferenceQueue(); }} onSelectGroup={handleParentReviewSelectGroup} onJumpToGroup={handleJumpToReviewGroup} onQueueHover={(queueKey) => { diff --git a/frontend/src/components/korea/ParentReviewPanel.tsx b/frontend/src/components/korea/ParentReviewPanel.tsx index f70a784..6dc629f 100644 --- a/frontend/src/components/korea/ParentReviewPanel.tsx +++ b/frontend/src/components/korea/ParentReviewPanel.tsx @@ -127,9 +127,22 @@ function topScorePct(item: GroupParentInferenceItem) { return Math.round((item.parentInference?.topScore ?? 0) * 100); } +const SOURCE_SHORT: Record = { + CORRELATION: 'CORR', + PREVIOUS_SELECTION: 'PREV', + DIRECT_PARENT_MATCH: 'DIRECT', + EPISODE_PRIOR: 'EPISODE', + MANUAL_LABEL: 'LABEL', +}; + +function shortenSource(s: string) { + return SOURCE_SHORT[s] ?? s; +} + function sourceList(candidate: ParentInferenceCandidate) { const raw = candidate.evidence?.sources; - return Array.isArray(raw) ? raw.join(', ') : candidate.candidateSource; + if (Array.isArray(raw)) return raw.map(shortenSource).join(', '); + return shortenSource(candidate.candidateSource); } function queueItemKey(item: GroupParentInferenceItem) { diff --git a/frontend/src/hooks/useGroupPolygons.ts b/frontend/src/hooks/useGroupPolygons.ts index 649c42b..3d6c621 100644 --- a/frontend/src/hooks/useGroupPolygons.ts +++ b/frontend/src/hooks/useGroupPolygons.ts @@ -38,8 +38,11 @@ export interface UseGroupPolygonsResult { allGroups: GroupPolygonDto[]; isLoading: boolean; lastUpdated: number; + refresh: () => Promise; } +const NOOP_REFRESH = async () => {}; + const EMPTY: UseGroupPolygonsResult = { fleetGroups: [], gearInZoneGroups: [], @@ -47,6 +50,7 @@ const EMPTY: UseGroupPolygonsResult = { allGroups: [], isLoading: false, lastUpdated: 0, + refresh: NOOP_REFRESH, }; export function useGroupPolygons(enabled: boolean): UseGroupPolygonsResult { @@ -92,5 +96,5 @@ export function useGroupPolygons(enabled: boolean): UseGroupPolygonsResult { if (!enabled) return EMPTY; - return { fleetGroups, gearInZoneGroups, gearOutZoneGroups, allGroups, isLoading, lastUpdated }; + return { fleetGroups, gearInZoneGroups, gearOutZoneGroups, allGroups, isLoading, lastUpdated, refresh: load }; } diff --git a/frontend/src/services/vesselAnalysis.ts b/frontend/src/services/vesselAnalysis.ts index a3ab21e..0d015b7 100644 --- a/frontend/src/services/vesselAnalysis.ts +++ b/frontend/src/services/vesselAnalysis.ts @@ -88,6 +88,7 @@ export interface GroupPolygonDto { members: MemberInfo[]; color: string; resolution?: '1h' | '1h-fb' | '6h'; + candidateCount?: number | null; parentInference?: ParentInferenceSummary | null; }