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:
부모
83db0f8149
커밋
0b74831b87
4
.gitignore
vendored
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;
|
||||
}
|
||||
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user