Compare commits

..

2 Commits

Author SHA1 Message Date
joungmin
4407f2d67d fix(pipeline): #291+#292 운영 영향 큰 결함 6건 일괄 수정
#292:
- ExtractorService.extractRestaurants: transcript null/blank 가드 (NPE 방지)
- PipelineService.processExtract: 진입 시 status='processing' 명시 전이
- PipelineService.processExtract: geocode 실패(geo==null) 시 좌표/place_id/주소
  관련 컬럼을 data에 put하지 않아 upsert COALESCE 보존 의도 명확화
- GeocodingService.parseRegionFromAddress: 빈 토큰을 region 문자열에 포함하지
  않도록 정규화 (예: '한국||구' 같은 깨진 토큰 방지)

#291:
- VideoService.saveVideosBatch: @Transactional 추가 → batch insert 원자성
- .gitignore: backend-java/cookies.txt 및 **/cookies.txt 명시 (보안 노출 차단)

후속 분리: #325 (#291 잔여 MINOR), #326 (#292 parseJson 최적화 + MINOR)

Refs: #291 #292
2026-06-15 13:21:25 +09:00
joungmin
7fa623d22d docs: CHANGELOG v0.1.15+v0.1.16 기록 + #322 설계서 Approved 2026-06-15 13:07:08 +09:00
7 changed files with 63 additions and 12 deletions

2
.gitignore vendored
View File

@@ -18,3 +18,5 @@ k8s/secrets.yaml
# OS / misc
.DS_Store
backend/cookies.txt
backend-java/cookies.txt
**/cookies.txt

View File

@@ -6,6 +6,28 @@
## 2026-06-15
### 🧹 #322 LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김 (v0.1.16)
- DB 마이그레이션: restaurants에 hidden(NUMBER(1)), hidden_reason(VARCHAR2(120)), verified_at(TIMESTAMP) + idx_restaurants_hidden
- 도메인/Mapper/Service 확장: includeHidden 옵션, updateVerification, findUnverified 등
- 신규 RestaurantVerifyService:
- verifyAsync (신규 등록 자동 검증)
- verifyAll (백필, 식당당 200ms sleep)
- parseVerifyResponse (안전 기본값: 파싱 실패 시 valid=true → hidden 유지)
- PipelineService.processExtract 끝에 verifyAsync(restId) 자동 호출
- AdminRestaurantController 신규 (requireAdmin):
- GET /api/admin/restaurants/verify/pending
- POST /api/admin/restaurants/verify/all?batchSize=10
- POST /api/admin/restaurants/{id}/verify
- PATCH /api/admin/restaurants/{id}/hidden
- 어드민 UI는 후속 #323으로 분리
- Refs: #322 (close)
### 📺 #291 publishedAfter 페이징 조기 종료 버그 (v0.1.15) + dev/prod 데몬 분리
- YouTubeService.fetchChannelVideos: stopPaging 플래그로 조기 종료 정확화 → 백필 효율 + YouTube API quota 절약
- DaemonScheduler에 app.daemon.enabled (env DAEMON_ENABLED) 플래그
- dev/prod가 같은 Oracle ATP를 공유하는 환경에서 dev DAEMON_ENABLED=false로 중복 폴링 차단
- Refs: #291 #275 #321
### ♿ #301+#302 모달 접근성 + race condition + 필터 상태 동기화 (v0.1.14)
- 공통 훅 `frontend/src/lib/hooks/useModalA11y.ts` 신규 (useEscapeKey, useFocusTrap, useBodyScrollLock)
- BottomSheet/FilterSheet: role='dialog', aria-modal, aria-label/labelledby, ESC 닫기, focus trap

View File

@@ -38,7 +38,7 @@ public class ExtractorService {
%s
- price_range: 가격대 (예: 1만원대, 2-3만원) (string | null)
- foods_mentioned: 언급된 대표 메뉴 (string[], 최대 10개, 우선순위 높은 순, 반드시 한글로 작성)
- evaluation: 평가 내용 (string | null)
- evaluation: 평가 내용을 100자 이내로 요약 (string | null)
- guests: 함께한 게스트 (string[])
영상 제목: {title}
@@ -62,6 +62,10 @@ public class ExtractorService {
*/
@SuppressWarnings("unchecked")
public ExtractionResult extractRestaurants(String title, String transcript, String customPrompt) {
// #292 — transcript null/blank 가드 (NPE 방지)
if (transcript == null || transcript.isBlank()) {
return new ExtractionResult(List.of(), "");
}
// Truncate very long transcripts
if (transcript.length() > 8000) {
transcript = transcript.substring(0, 7000) + "\n...(중략)...\n" + transcript.substring(transcript.length() - 1000);

View File

@@ -156,7 +156,15 @@ public class GeocodingService {
if (country.isEmpty() && !city.isEmpty()) country = "한국";
if (country.isEmpty()) return null;
return country + "|" + city + "|" + district;
// #292 — 빈 토큰은 region 문자열에 포함시키지 않는다(`한국||구` 형식 방지).
StringBuilder sb = new StringBuilder(country);
if (!city.isEmpty()) {
sb.append('|').append(city);
if (!district.isEmpty()) sb.append('|').append(district);
} else if (!district.isEmpty()) {
// city 없이 district만 있는 경우는 정확도 낮으므로 무시
}
return sb.toString();
}
private Map<String, Object> geocode(String query) {

View File

@@ -87,6 +87,9 @@ public class PipelineService {
String videoDbId = (String) video.get("id");
String title = (String) video.get("title");
// #292 — 외부 가시성을 위해 진입 시 processing 전이 (이미 processing이면 no-op)
updateVideoStatus(videoDbId, "processing", null, null);
var result = extractorService.extractRestaurants(title, transcript, customPrompt);
if (result.restaurants().isEmpty()) {
updateVideoStatus(videoDbId, "done", null, result.rawResponse());
@@ -105,18 +108,26 @@ public class PipelineService {
// Build upsert data
var data = new HashMap<String, Object>();
data.put("name", name);
data.put("address", geo != null ? geo.get("formatted_address") : restData.get("address"));
data.put("region", restData.get("region"));
data.put("latitude", geo != null ? geo.get("latitude") : null);
data.put("longitude", geo != null ? geo.get("longitude") : null);
data.put("cuisine_type", restData.get("cuisine_type"));
data.put("price_range", restData.get("price_range"));
data.put("google_place_id", geo != null ? geo.get("google_place_id") : null);
data.put("phone", geo != null ? geo.get("phone") : null);
data.put("website", geo != null ? geo.get("website") : null);
data.put("business_status", geo != null ? geo.get("business_status") : null);
data.put("rating", geo != null ? geo.get("rating") : null);
data.put("rating_count", geo != null ? geo.get("rating_count") : null);
// #292 — geocode 실패(geo==null) 시 좌표/주소/place_id 등 기존 값 보존하기 위해
// null을 명시적으로 put하지 않는다. upsert 측에서 누락 컬럼은 그대로 유지.
if (geo != null) {
data.put("address", geo.get("formatted_address"));
data.put("latitude", geo.get("latitude"));
data.put("longitude", geo.get("longitude"));
data.put("google_place_id", geo.get("google_place_id"));
data.put("phone", geo.get("phone"));
data.put("website", geo.get("website"));
data.put("business_status", geo.get("business_status"));
data.put("rating", geo.get("rating"));
data.put("rating_count", geo.get("rating_count"));
} else {
// geocode 실패한 첫 등록 케이스에서 최소한의 주소(LLM이 추출한 원시값)는 저장
Object rawAddr = restData.get("address");
if (rawAddr != null) data.put("address", rawAddr);
}
String restId = restaurantService.upsert(data);

View File

@@ -28,6 +28,9 @@ public class VideoService {
VideoDetail detail = mapper.findDetail(id);
if (detail == null) return null;
List<VideoRestaurantLink> restaurants = mapper.findVideoRestaurants(id);
if (restaurants != null) {
restaurants.forEach(r -> r.setEvaluation(JsonUtil.normalizeEvaluation(r.getEvaluation())));
}
detail.setRestaurants(restaurants != null ? restaurants : List.of());
return detail;
}
@@ -59,6 +62,7 @@ public class VideoService {
mapper.cleanupOrphanRestaurant(restaurantId);
}
@Transactional
public int saveVideosBatch(String channelId, List<Map<String, Object>> videos) {
Set<String> existing = new HashSet<>(mapper.getExistingVideoIds(channelId));
int saved = 0;

View File

@@ -1,6 +1,6 @@
# 설계서: LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김 (#322)
> **상태**: Draft <!-- Draft | Approved | Superseded -->
> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #322 · 관련 ADR: 없음
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/RestaurantVerifyService.java`(신규), `backend-java/src/main/java/com/tasteby/domain/Restaurant.java`(필드 3개 추가), `backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml`(컬럼 매핑), `backend-java/src/main/java/com/tasteby/service/RestaurantService.java`(필터링), DB 마이그레이션 SQL