fix: 모선 검토 대기 목록을 폴리곤 폴링 데이터에서 파생하여 동기화 문제 해소

- 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) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-04 10:21:28 +09:00
부모 83db0f8149
커밋 0b74831b87
8개의 변경된 파일125개의 추가작업 그리고 87개의 파일을 삭제

4
.gitignore vendored
파일 보기

@ -29,6 +29,10 @@ coverage/
.prettiercache
*.tsbuildinfo
# === Codex CLI ===
AGENTS.md
.codex/
# === Claude Code ===
# 글로벌 gitignore에서 .claude/ 전체를 무시하므로 팀 파일을 명시적으로 포함
!.claude/

파일 보기

@ -175,6 +175,11 @@ deploy/ # systemd + nginx 배포 설정
3. **API 응답 검증**: MR 생성, 봇 승인, 머지 API 호출 후 반드시 상태를 확인.
실패 시 사용자에게 알리고 중단.
**MR/머지는 사용자 명시적 요청 없이 절대 진행 금지:**
- `git push` 완료 후에는 "푸시 완료" 보고만 하고 **중단**
- MR 생성은 사용자가 `/mr`, `/release` 호출 또는 "MR 해줘" 등 명시적 요청 시에만
- **스킬 없이 Gitea API를 직접 호출하여 MR/머지를 진행하지 말 것** — 스킬 절차(사용자 확인 단계)를 우회하는 것과 동일
### 스킬 목록
- `/push` — 커밋 + 푸시 (해시 비교 → 커밋 메시지 확인 → 푸시)
- `/mr` — 커밋 + 푸시 + MR 생성 (릴리즈 노트 갱신 포함)

파일 보기

@ -26,5 +26,6 @@ public class GroupPolygonDto {
private List<Map<String, Object>> members;
private String color;
private String resolution;
private Integer candidateCount;
private ParentInferenceSummaryDto parentInference;
}

파일 보기

@ -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();
}

파일 보기

@ -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<Set<string>>(new Set(['identity', 'default', 'aggressive', 'conservative', 'proximity-heavy', 'visit-pattern']));
const [hoveredTarget, setHoveredTarget] = useState<{ mmsi: string; model: string } | null>(null);
const [parentInferenceItems, setParentInferenceItems] = useState<GroupParentInferenceItem[]>([]);
const [parentInferenceQueue, setParentInferenceQueue] = useState<GroupParentInferenceItem[]>([]);
const [parentActiveGroupExclusions, setParentActiveGroupExclusions] = useState<ParentCandidateExclusion[]>([]);
const [parentActiveGlobalExclusions, setParentActiveGlobalExclusions] = useState<ParentCandidateExclusion[]>([]);
const [parentActiveLabelSessions, setParentActiveLabelSessions] = useState<ParentLabelSession[]>([]);
@ -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<unknown>[] = [];
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<string>() : reviewDrivenOverlayMode ? new Set<string>(['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 && (
<ParentReviewPanel
selectedGearGroup={selectedGearGroup}
selectedGearGroup={selectedGearGroup ?? ''}
items={parentInferenceItems}
reviewQueue={displayedParentInferenceQueue}
reviewQueueFilteredCount={filteredParentInferenceQueue.length}
@ -1446,7 +1426,7 @@ export function FleetClusterLayer({
spatialFilterPointCount={reviewSpatialFilterPoints.length}
onActorChange={setParentReviewActor}
onWorkflowDurationDaysChange={(value) => setStoredParentWorkflowDurationDays(sanitizeWorkflowDurationDays(value))}
onRefresh={() => { void reloadParentInference(selectedGearGroup); }}
onRefresh={() => { void refreshParentInferenceQueue(); }}
onSelectGroup={handleParentReviewSelectGroup}
onJumpToGroup={handleJumpToReviewGroup}
onQueueHover={(queueKey) => {

파일 보기

@ -127,9 +127,22 @@ function topScorePct(item: GroupParentInferenceItem) {
return Math.round((item.parentInference?.topScore ?? 0) * 100);
}
const SOURCE_SHORT: Record<string, string> = {
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) {

파일 보기

@ -38,8 +38,11 @@ export interface UseGroupPolygonsResult {
allGroups: GroupPolygonDto[];
isLoading: boolean;
lastUpdated: number;
refresh: () => Promise<void>;
}
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 };
}

파일 보기

@ -88,6 +88,7 @@ export interface GroupPolygonDto {
members: MemberInfo[];
color: string;
resolution?: '1h' | '1h-fb' | '6h';
candidateCount?: number | null;
parentInference?: ParentInferenceSummary | null;
}