Compare commits

...

7 Commits

Author SHA1 Message Date
joungmin
9c2dc9f43a fix(search): #293 검색/벡터 결함 7건 일괄 수정
- SearchController: q 빈값 가드 (HTTP 400) — '%%' LIKE 응답 폭발 차단
- SearchService:
  - keywordSearch: LIKE 와일드카드 escape (%, _, \\)
  - hybrid 모드: semantic 결과에도 attachChannels 호출 (이전: keyword만)
  - ObjectMapper/TypeReference static 재사용 (캐시 hit 경로 GC 압박 완화)
  - 알 수 없는 mode → warn 로그 + keyword fallback (이전: silent)
  - maxDistance를 @Value("${app.search.max-distance:0.57}")로 외부화
- SearchMapper.xml: LIKE 절에 ESCAPE '\\' 추가
- VectorService.searchSimilar: embeddings/first list null/empty 가드 (NPE 방지)
- application.yml: app.search.max-distance (env SEARCH_MAX_DISTANCE) 추가

후속 분리: batch insert + 테스트 (별도 후속 이슈)

Refs: #293
2026-06-15 14:01:59 +09:00
joungmin
7779d5ddfd docs(changelog): v0.1.18 어드민 검증 UI 기록 (#304+#323) 2026-06-15 13:58:56 +09:00
joungmin
6ea82a5561 feat(admin): #304+#323 LLM 검증 UI + 공통 유틸 추출
#323 (LLM 검증 어드민 UI):
- api.ts: getVerifyPending / verifyAll / verifyOne / setRestaurantHidden 추가
- Restaurant 타입에 hidden / hidden_reason / verified_at 추가
- RestaurantsPanel 헤더에 "미검증 N건 + LLM 검증" 버튼 추가
- 테이블에 "검증" 컬럼 추가:
  - hidden=true → "숨김 (사유)" 버튼 (클릭 시 해제)
  - verified_at 있고 visible → "OK" 버튼 (클릭 시 숨김)
  - 미검증 → "미검증" 텍스트

#304 (어드민 공통 유틸):
- lib/admin-utils.ts 신규
  - getAdminToken(): localStorage 직접 접근 통일
  - authHeaders(): 표준 Bearer 헤더
  - consumeSseStream(): SSE 라인 파싱 헬퍼
- colSpan 6 → 7로 검증 컬럼 반영

후속 분리: #329 (admin/page.tsx 전체 분리 + localStorage/SSE 호출 11+곳 교체)

Refs: #304 #323 #322
2026-06-15 13:57:33 +09:00
joungmin
04c54d1b1a docs(changelog): v0.1.17 백엔드 결함 일괄 수정 기록 (#291+#292) 2026-06-15 13:23:51 +09:00
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
joungmin
d2e78b0363 feat(verify): #322 LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김
DB 마이그레이션 (운영 ATP에 사전 실행 완료):
- restaurants.hidden NUMBER(1) DEFAULT 0 NOT NULL
- restaurants.hidden_reason VARCHAR2(120)
- restaurants.verified_at TIMESTAMP
- idx_restaurants_hidden 인덱스

코드:
- Restaurant 도메인에 hidden/hiddenReason/verifiedAt 필드 추가
- RestaurantMapper.xml resultMap 갱신 + findAll에 hidden=0 조건 (includeHidden=true 시 제외)
- RestaurantMapper에 updateVerification/clearHidden/findUnverified/countUnverified 추가
- RestaurantService.findAll() includeHidden 오버로드 + 검증 헬퍼 메서드
- RestaurantVerifyService 신규 (verify, verifyAsync, verifyAll, buildPrompt, parseVerifyResponse)
  - LLM 응답이 JSON 아닐 때 안전 기본값(valid=true) → hidden 유지
  - 백필은 식당당 200ms sleep으로 LLM rate 보호
- 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 {hidden, reason}

프롬프트:
- 식당명, 주소, 지역, cuisine, foods를 OCI GenAI로 보내 valid/is_franchise/reason 판정
- 보수적 가이드 (모호하면 valid=true)

설계서: docs/design/322-restaurant-llm-verify/README.md (Approved 대기)

Refs: #322
2026-06-15 13:04:23 +09:00
22 changed files with 842 additions and 36 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,49 @@
## 2026-06-15
### 🛠 #304+#323 어드민 LLM 검증 UI + 공통 유틸 (v0.1.18)
- 신규 frontend/src/lib/admin-utils.ts:
- getAdminToken / authHeaders / consumeSseStream
- api.ts: Restaurant 타입에 hidden/hidden_reason/verified_at + verify/setRestaurantHidden API 4개
- RestaurantsPanel:
- 헤더: "미검증 N건 + LLM 검증" 버튼
- 테이블: 검증 컬럼 (숨김/OK/미검증 배지 + 클릭으로 토글)
- colSpan 7로 수정
- 후속 분리: #329 (admin 전체 파일 분리 + localStorage/SSE 11+곳 통일)
- Refs: #304 #323 #322 (close)
### 🔧 #291+#292 백엔드 결함 일괄 수정 (v0.1.17)
- ExtractorService: transcript null/blank 가드 (NPE 방지)
- PipelineService.processExtract: 진입 시 status='processing' 명시 전이 (SSE/사용자 가시성)
- PipelineService: geocode 실패 시 좌표/place_id/주소 컬럼을 data에 put하지 않아 upsert COALESCE 보존 의도 명확화
- GeocodingService.parseRegionFromAddress: 빈 토큰을 region 문자열에서 제거 ('한국||구' 깨짐 방지)
- VideoService.saveVideosBatch: @Transactional 추가 → batch insert 원자성
- .gitignore: backend-java/cookies.txt 및 **/cookies.txt
- 후속 분리: #325 (#291 잔여 MINOR), #326 (parseJson 최적화 + #292 MINOR)
- Refs: #291 #292 (close)
### 🧹 #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

@@ -0,0 +1,81 @@
package com.tasteby.controller;
import com.tasteby.security.AuthUtil;
import com.tasteby.service.RestaurantService;
import com.tasteby.service.RestaurantVerifyService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* #322 LLM 검증 어드민 API.
* - hidden 토글
* - 일괄 백필
*/
@RestController
@RequestMapping("/api/admin/restaurants")
public class AdminRestaurantController {
private static final Logger log = LoggerFactory.getLogger(AdminRestaurantController.class);
private final RestaurantService restaurantService;
private final RestaurantVerifyService verifyService;
public AdminRestaurantController(RestaurantService restaurantService, RestaurantVerifyService verifyService) {
this.restaurantService = restaurantService;
this.verifyService = verifyService;
}
/**
* 어드민용 검증 안 된 식당 수 조회.
*/
@GetMapping("/verify/pending")
public Map<String, Object> pendingCount() {
var admin = AuthUtil.requireAdmin();
int n = restaurantService.countUnverified();
log.info("[ADMIN] {} pending verify count: {}", admin.getSubject(), n);
return Map.of("pending", n);
}
/**
* 어드민용 일괄 백필 트리거. 한 번 호출에 모든 미검증 식당을 처리.
* 비동기/SSE 없이 동기 응답이라 호출자는 결과까지 기다려야 함(LLM × N).
*/
@PostMapping("/verify/all")
public Map<String, Object> verifyAll(@RequestParam(defaultValue = "10") int batchSize) {
var admin = AuthUtil.requireAdmin();
log.info("[ADMIN] {} triggered verifyAll(batchSize={})", admin.getSubject(), batchSize);
int processed = verifyService.verifyAll(batchSize);
return Map.of("processed", processed);
}
/**
* 어드민용 단건 재검증.
*/
@PostMapping("/{id}/verify")
public Map<String, Object> verifyOne(@PathVariable String id) {
var admin = AuthUtil.requireAdmin();
log.info("[ADMIN] {} verifyOne({})", admin.getSubject(), id);
verifyService.verify(id);
return Map.of("success", true, "id", id);
}
/**
* 어드민용 hidden 토글.
*/
@PatchMapping("/{id}/hidden")
public Map<String, Object> setHidden(@PathVariable String id, @RequestBody Map<String, Object> body) {
var admin = AuthUtil.requireAdmin();
boolean hidden = Boolean.TRUE.equals(body.get("hidden"));
String reason = body.get("reason") instanceof String s ? s : "manual";
if (hidden) {
restaurantService.markHidden(id, reason);
} else {
restaurantService.clearHidden(id);
}
log.info("[ADMIN] {} set hidden={} for {}", admin.getSubject(), hidden, id);
return Map.of("success", true, "id", id, "hidden", hidden);
}
}

View File

@@ -2,7 +2,9 @@ package com.tasteby.controller;
import com.tasteby.domain.Restaurant;
import com.tasteby.service.SearchService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@@ -21,7 +23,12 @@ public class SearchController {
@RequestParam String q,
@RequestParam(defaultValue = "keyword") String mode,
@RequestParam(defaultValue = "20") int limit) {
// #293 — q 빈값 가드: '%%' LIKE로 응답 폭발 차단
if (q == null || q.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "검색어가 필요합니다");
}
if (limit > 100) limit = 100;
return searchService.search(q, mode, limit);
if (limit < 1) limit = 1;
return searchService.search(q.trim(), mode, limit);
}
}

View File

@@ -31,6 +31,11 @@ public class Restaurant {
private Integer ratingCount;
private Date updatedAt;
// #322 LLM 검증
private Boolean hidden;
private String hiddenReason;
private Date verifiedAt;
// Transient enrichment fields
private List<String> channels;
private List<String> foodsMentioned;

View File

@@ -14,7 +14,19 @@ public interface RestaurantMapper {
@Param("offset") int offset,
@Param("cuisine") String cuisine,
@Param("region") String region,
@Param("channel") String channel);
@Param("channel") String channel,
@Param("includeHidden") boolean includeHidden);
// #322 LLM 검증: hidden 표시 갱신
void updateVerification(@Param("id") String id,
@Param("hidden") int hidden,
@Param("hiddenReason") String hiddenReason);
void clearHidden(@Param("id") String id);
List<Restaurant> findUnverified(@Param("limit") int limit);
int countUnverified();
Restaurant findById(@Param("id") String id);

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

@@ -28,6 +28,7 @@ public class PipelineService {
private final VideoService videoService;
private final VectorService vectorService;
private final CacheService cacheService;
private final RestaurantVerifyService verifyService;
public PipelineService(YouTubeService youTubeService,
ExtractorService extractorService,
@@ -35,7 +36,8 @@ public class PipelineService {
RestaurantService restaurantService,
VideoService videoService,
VectorService vectorService,
CacheService cacheService) {
CacheService cacheService,
RestaurantVerifyService verifyService) {
this.youTubeService = youTubeService;
this.extractorService = extractorService;
this.geocodingService = geocodingService;
@@ -43,6 +45,7 @@ public class PipelineService {
this.videoService = videoService;
this.vectorService = vectorService;
this.cacheService = cacheService;
this.verifyService = verifyService;
}
/**
@@ -84,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());
@@ -102,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);
@@ -150,6 +164,9 @@ public class PipelineService {
count++;
log.info("Saved restaurant: {} (geocoded={})", name, geo != null);
// #322 — 등록 직후 비동기 LLM 검증
verifyService.verifyAsync(restId);
}
updateVideoStatus(videoDbId, "done", null, result.rawResponse());

View File

@@ -21,11 +21,36 @@ public class RestaurantService {
}
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel) {
List<Restaurant> restaurants = mapper.findAll(limit, offset, cuisine, region, channel);
return findAll(limit, offset, cuisine, region, channel, false);
}
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel, boolean includeHidden) {
List<Restaurant> restaurants = mapper.findAll(limit, offset, cuisine, region, channel, includeHidden);
enrichRestaurants(restaurants);
return restaurants;
}
// #322 — 검증 상태 갱신
public void markHidden(String id, String reason) {
mapper.updateVerification(id, 1, reason);
}
public void markVerifiedClean(String id) {
mapper.updateVerification(id, 0, null);
}
public void clearHidden(String id) {
mapper.clearHidden(id);
}
public List<Restaurant> findUnverified(int limit) {
return mapper.findUnverified(limit);
}
public int countUnverified() {
return mapper.countUnverified();
}
public List<Restaurant> findWithoutTabling() {
return mapper.findWithoutTabling();
}
@@ -117,7 +142,8 @@ public class RestaurantService {
String id = IdGenerator.newId();
String foodsJson = foods != null ? JsonUtil.toJson(foods) : null;
String guestsJson = guests != null ? JsonUtil.toJson(guests) : null;
mapper.linkVideoRestaurant(id, videoId, restaurantId, foodsJson, evaluation, guestsJson);
String evalJson = JsonUtil.normalizeEvaluation(evaluation);
mapper.linkVideoRestaurant(id, videoId, restaurantId, foodsJson, evalJson, guestsJson);
}
public void updateCuisineType(String id, String cuisineType) {

View File

@@ -0,0 +1,149 @@
package com.tasteby.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tasteby.domain.Restaurant;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* #322 LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김.
* 설계서: docs/design/322-restaurant-llm-verify/README.md
*/
@Service
public class RestaurantVerifyService {
private static final Logger log = LoggerFactory.getLogger(RestaurantVerifyService.class);
private final RestaurantService restaurantService;
private final OciGenAiService genAi;
private final ObjectMapper jsonMapper = new ObjectMapper();
// 백필 시 LLM rate-limit 보호용 sleep (ms)
private static final long BACKFILL_SLEEP_MS = 200;
public RestaurantVerifyService(RestaurantService restaurantService, OciGenAiService genAi) {
this.restaurantService = restaurantService;
this.genAi = genAi;
}
@Async
public void verifyAsync(String restaurantId) {
try {
verify(restaurantId);
} catch (Exception e) {
log.warn("verifyAsync failed for {}: {}", restaurantId, e.getMessage());
}
}
public void verify(String restaurantId) {
Restaurant r = restaurantService.findById(restaurantId);
if (r == null) return;
VerifyResult result;
try {
String prompt = buildPrompt(r);
String response = genAi.chat(prompt, 120);
result = parseVerifyResponse(response);
} catch (Exception e) {
// 안전한 기본값: LLM 실패 시 공개 유지(=hidden=0). verified_at은 미설정으로 남겨 재시도 가능.
log.warn("verify({}) LLM failed: {} — keeping visible", restaurantId, e.getMessage());
return;
}
applyResult(restaurantId, result);
}
/**
* 미검증(verified_at IS NULL) 식당을 배치로 검증. 운영 trigger 용.
* 반환: 이번 호출에서 처리한 개수.
*/
public int verifyAll(int batchSize) {
int total = 0;
List<Restaurant> batch;
while (!(batch = restaurantService.findUnverified(batchSize)).isEmpty()) {
for (Restaurant r : batch) {
try {
verify(r.getId());
} catch (Exception e) {
log.warn("verifyAll({}) failed: {}", r.getId(), e.getMessage());
}
total++;
try { Thread.sleep(BACKFILL_SLEEP_MS); } catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return total;
}
}
if (batch.size() < batchSize) break;
}
return total;
}
// ---- pure helpers (tested separately) ----
String buildPrompt(Restaurant r) {
String foods = r.getFoodsMentioned() == null || r.getFoodsMentioned().isEmpty()
? "(없음)" : String.join(", ", r.getFoodsMentioned());
return "당신은 식당 데이터 큐레이터다. 다음 식당이 (1) 실제 운영 식당인지, (2) 흔한 프랜차이즈인지 판정하라.\n\n" +
"식당명: " + safe(r.getName()) + "\n" +
"주소: " + safe(r.getAddress()) + "\n" +
"지역: " + safe(r.getRegion()) + "\n" +
"음식 분류: " + safe(r.getCuisineType()) + "\n" +
"언급된 음식: " + foods + "\n\n" +
"응답 형식(JSON만, 다른 텍스트 없이):\n" +
"{\"valid\": true|false, \"is_franchise\": true|false, \"reason\": \"20자 이내\"}\n\n" +
"가이드:\n" +
"- valid=false: 식당 이름이 사람 이름, 영상 제목 일부, 일반 명사(\"점심\", \"맛집\"), " +
"영문 prefix(\"name:\", \"title:\") 등 분명히 식당이 아닌 경우.\n" +
"- is_franchise=true: 스타벅스, 맥도날드, 버거킹, 김밥천국, 본죽 등 전국 50개 이상 매장의 흔한 체인.\n" +
"- 판단이 모호하면 valid=true, is_franchise=false (보수적).";
}
VerifyResult parseVerifyResponse(String raw) {
if (raw == null) return VerifyResult.safeDefault();
String json = extractJson(raw);
if (json == null) return VerifyResult.safeDefault();
try {
JsonNode node = jsonMapper.readTree(json);
boolean valid = node.path("valid").asBoolean(true);
boolean isFranchise = node.path("is_franchise").asBoolean(false);
String reason = node.path("reason").asText("");
if (reason.length() > 100) reason = reason.substring(0, 100);
return new VerifyResult(valid, isFranchise, reason);
} catch (Exception e) {
return VerifyResult.safeDefault();
}
}
private void applyResult(String id, VerifyResult r) {
if (!r.valid()) {
restaurantService.markHidden(id, truncate("not_restaurant: " + r.reason(), 120));
} else if (r.isFranchise()) {
restaurantService.markHidden(id, truncate("franchise: " + r.reason(), 120));
} else {
restaurantService.markVerifiedClean(id);
}
}
private static final Pattern JSON_BLOCK = Pattern.compile("\\{[^{}]*\\}", Pattern.DOTALL);
private static String extractJson(String raw) {
// 우선 그대로 시도
String trimmed = raw.trim();
if (trimmed.startsWith("{") && trimmed.endsWith("}")) return trimmed;
// 마크다운 코드블록 또는 다른 텍스트에 감싸진 경우 정규식 추출
Matcher m = JSON_BLOCK.matcher(raw);
return m.find() ? m.group() : null;
}
private static String safe(String s) { return s == null ? "(미상)" : s; }
private static String truncate(String s, int max) { return s.length() <= max ? s : s.substring(0, max); }
public record VerifyResult(boolean valid, boolean isFranchise, String reason) {
public static VerifyResult safeDefault() { return new VerifyResult(true, false, "parse_failed"); }
}
}

View File

@@ -1,9 +1,12 @@
package com.tasteby.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tasteby.domain.Restaurant;
import com.tasteby.mapper.SearchMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.*;
@@ -12,12 +15,17 @@ import java.util.*;
public class SearchService {
private static final Logger log = LoggerFactory.getLogger(SearchService.class);
private static final ObjectMapper JSON = new ObjectMapper();
private static final TypeReference<List<Restaurant>> LIST_TYPE = new TypeReference<>() {};
private final SearchMapper searchMapper;
private final RestaurantService restaurantService;
private final VectorService vectorService;
private final CacheService cache;
@Value("${app.search.max-distance:0.57}")
private double maxDistance;
public SearchService(SearchMapper searchMapper,
RestaurantService restaurantService,
VectorService vectorService,
@@ -33,8 +41,8 @@ public class SearchService {
String cached = cache.getRaw(key);
if (cached != null) {
try {
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
return mapper.readValue(cached, new com.fasterxml.jackson.core.type.TypeReference<List<Restaurant>>() {});
// #293 — ObjectMapper 재사용 (필드 static)
return JSON.readValue(cached, LIST_TYPE);
} catch (Exception ignored) {}
}
@@ -44,13 +52,20 @@ public class SearchService {
case "hybrid" -> {
var kw = keywordSearch(q, limit);
var sem = semanticSearch(q, limit);
// #293 — semantic 결과에도 channels 부착 (이전: keyword에만 부착되어 hybrid에서 sem 결과는 채널 누락)
if (!sem.isEmpty()) attachChannels(sem);
Set<String> seen = new HashSet<>();
var merged = new ArrayList<Restaurant>();
for (var r : kw) { if (seen.add(r.getId())) merged.add(r); }
for (var r : sem) { if (seen.add(r.getId())) merged.add(r); }
result = merged.size() > limit ? merged.subList(0, limit) : merged;
}
default -> result = keywordSearch(q, limit);
case "keyword" -> result = keywordSearch(q, limit);
default -> {
// #293 — 알 수 없는 mode는 silent fallback 대신 경고 로그
log.warn("Unknown search mode '{}', falling back to keyword", mode);
result = keywordSearch(q, limit);
}
}
cache.set(key, result);
@@ -58,7 +73,10 @@ public class SearchService {
}
private List<Restaurant> keywordSearch(String q, int limit) {
String pattern = "%" + q + "%";
// #293 — LIKE 와일드카드 escape: 사용자 입력의 %, _, \ 를 리터럴로 처리.
// SQL에서는 ESCAPE '\\' 절을 사용 (SearchMapper.xml).
String escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
String pattern = "%" + escaped + "%";
List<Restaurant> results = searchMapper.keywordSearch(pattern, limit);
if (!results.isEmpty()) {
attachChannels(results);
@@ -68,7 +86,7 @@ public class SearchService {
private List<Restaurant> semanticSearch(String q, int limit) {
try {
var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), 0.57);
var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), maxDistance);
if (similar.isEmpty()) return List.of();
Set<String> seen = new LinkedHashSet<>();

View File

@@ -27,12 +27,15 @@ public class VectorService {
*/
public List<Map<String, Object>> searchSimilar(String query, int topK, double maxDistance) {
List<List<Double>> embeddings = genAi.embedTexts(List.of(query));
if (embeddings.isEmpty()) return List.of();
// #293 — embeddings 빈/null 가드 (NPE/IndexOutOfBoundsException 방지)
if (embeddings == null || embeddings.isEmpty()) return List.of();
List<Double> first = embeddings.getFirst();
if (first == null || first.isEmpty()) return List.of();
// Convert to float array for Oracle VECTOR type
float[] queryVec = new float[embeddings.getFirst().size()];
float[] queryVec = new float[first.size()];
for (int i = 0; i < queryVec.length; i++) {
queryVec[i] = embeddings.getFirst().get(i).floatValue();
queryVec[i] = first.get(i).floatValue();
}
String sql = """

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

@@ -59,6 +59,11 @@ app:
cache:
ttl-seconds: 600
search:
# #293 — 벡터 검색 cosine distance 임계값 (0.0=완전일치, 1.0=직교).
# 0.57은 cohere embed-v4 한국어 시맨틱 적합도 기준 경험값.
max-distance: ${SEARCH_MAX_DISTANCE:0.57}
daemon:
# 인스턴스 차원 스케줄러 활성화. dev/prod가 같은 DB를 공유하므로
# dev .env에 DAEMON_ENABLED=false를 설정해 dev 폴링을 끄고 prod만 동작시킨다.

View File

@@ -0,0 +1,7 @@
-- #322 LLM 검증 — restaurants에 hidden/검증 컬럼 추가
ALTER TABLE restaurants ADD (
hidden NUMBER(1) DEFAULT 0 NOT NULL,
hidden_reason VARCHAR2(120),
verified_at TIMESTAMP
);
CREATE INDEX idx_restaurants_hidden ON restaurants(hidden);

View File

@@ -22,6 +22,9 @@
<result property="rating" column="rating"/>
<result property="ratingCount" column="rating_count"/>
<result property="updatedAt" column="updated_at"/>
<result property="hidden" column="hidden" javaType="java.lang.Boolean"/>
<result property="hiddenReason" column="hidden_reason"/>
<result property="verifiedAt" column="verified_at"/>
</resultMap>
<!-- ===== Queries ===== -->
@@ -29,7 +32,8 @@
<select id="findAll" resultMap="restaurantMap">
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
r.cuisine_type, r.price_range, r.google_place_id, r.tabling_url, r.catchtable_url,
r.business_status, r.rating, r.rating_count, r.updated_at
r.business_status, r.rating, r.rating_count, r.updated_at,
r.hidden, r.hidden_reason, r.verified_at
FROM restaurants r
<if test="channel != null and channel != ''">
JOIN video_restaurants vr_f ON vr_f.restaurant_id = r.id
@@ -39,6 +43,9 @@
<where>
r.latitude IS NOT NULL
AND EXISTS (SELECT 1 FROM video_restaurants vr0 WHERE vr0.restaurant_id = r.id)
<if test="includeHidden == null or !includeHidden">
AND r.hidden = 0
</if>
<if test="cuisine != null and cuisine != ''">
AND r.cuisine_type = #{cuisine}
</if>
@@ -277,4 +284,35 @@
ORDER BY r.name
</select>
<!-- ===== #322 LLM 검증 ===== -->
<update id="updateVerification">
UPDATE restaurants
SET hidden = #{hidden},
hidden_reason = #{hiddenReason,jdbcType=VARCHAR},
verified_at = CURRENT_TIMESTAMP
WHERE id = #{id}
</update>
<update id="clearHidden">
UPDATE restaurants
SET hidden = 0,
hidden_reason = NULL,
verified_at = CURRENT_TIMESTAMP
WHERE id = #{id}
</update>
<select id="findUnverified" resultMap="restaurantMap">
SELECT r.id, r.name, r.address, r.region, r.cuisine_type, r.price_range,
r.hidden, r.hidden_reason, r.verified_at
FROM restaurants r
WHERE r.verified_at IS NULL
ORDER BY r.updated_at DESC
FETCH FIRST #{limit} ROWS ONLY
</select>
<select id="countUnverified" resultType="int">
SELECT COUNT(*) FROM restaurants WHERE verified_at IS NULL
</select>
</mapper>

View File

@@ -30,12 +30,13 @@
JOIN video_restaurants vr ON vr.restaurant_id = r.id
JOIN videos v ON v.id = vr.video_id
WHERE r.latitude IS NOT NULL
AND (UPPER(r.name) LIKE UPPER(#{query})
OR UPPER(r.address) LIKE UPPER(#{query})
OR UPPER(r.region) LIKE UPPER(#{query})
OR UPPER(r.cuisine_type) LIKE UPPER(#{query})
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query})
OR UPPER(v.title) LIKE UPPER(#{query}))
<!-- #293 — ESCAPE 절로 사용자 입력의 %, _ 와일드카드 의도 우회 차단 -->
AND (UPPER(r.name) LIKE UPPER(#{query}) ESCAPE '\'
OR UPPER(r.address) LIKE UPPER(#{query}) ESCAPE '\'
OR UPPER(r.region) LIKE UPPER(#{query}) ESCAPE '\'
OR UPPER(r.cuisine_type) LIKE UPPER(#{query}) ESCAPE '\'
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query}) ESCAPE '\'
OR UPPER(v.title) LIKE UPPER(#{query}) ESCAPE '\')
FETCH FIRST #{limit} ROWS ONLY
</select>

View File

@@ -0,0 +1,187 @@
# 설계서: LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김 (#322)
> **상태**: 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
> · 테스트: 단위 테스트 신규 (검증 결과 파싱, hidden 필터링)
## 1. 목적 (Why)
LLM 추출 과정에서 (a) 식당이 아닌 비식별자(영상 제목, 사람 이름, 일반 명사 등)가 식당으로 잘못 등록되거나 (b) 흔한 프랜차이즈(스타벅스, 맥도날드 등)가 큐레이션 의도와 무관하게 등록되어 사용자 경험을 저해. LLM 2차 검증으로 자동 숨김 처리하고 어드민에서 수동 복구 가능하게 한다.
## 2. 범위 (Scope)
- **포함**
- `restaurants` 테이블 컬럼 추가: `hidden NUMBER(1) DEFAULT 0`, `hidden_reason VARCHAR2(120)`, `verified_at TIMESTAMP`.
- 신규 `RestaurantVerifyService`: 단건 검증 + 배치 백필 검증.
- `PipelineService.processExtract` 흐름 끝에 검증 호출(신규 등록 자동 검증).
- 어드민 API: 일괄 재검증 트리거 + 개별 hidden 토글.
- 프론트: 공개 API 응답에서 hidden=true 제외(어드민 응답에는 포함).
- **제외 (out of scope)**
- 이미지 인식, 메뉴 검증.
- 프랜차이즈 매칭 전용 DB/지식베이스(이번엔 LLM 단발 판정).
- 어드민 UI 대량 작업(필요 시 후속 이슈).
## 3. 인수조건 (Acceptance Criteria)
- [ ] `restaurants` 테이블에 `hidden`/`hidden_reason`/`verified_at` 3개 컬럼이 존재한다.
- [ ] 신규 식당 등록 후 60초 이내 `verified_at`이 설정된다.
- [ ] `GET /api/restaurants` 응답에 `hidden=1` 식당은 포함되지 않는다.
- [ ] `GET /api/admin/restaurants?include_hidden=true` 는 hidden을 포함하고 `hidden_reason`을 노출한다.
- [ ] 어드민 `PATCH /api/admin/restaurants/{id}/hidden {hidden:false}` 토글이 정상 동작한다.
- [ ] 어드민 `POST /api/admin/restaurants/verify-all` 호출 시 미검증 식당 전체를 백필(rate-limit 적용).
- [ ] LLM 호출 실패 시 식당은 hidden=0(공개) 유지(안전한 기본값) + 로그.
- [ ] 단위 테스트로 LLM 응답 파싱 + 필터링 로직 통과.
## 4. 컨텍스트 & 제약
- 의존성: 기존 `OciGenAiService.chat(prompt, maxTokens)` 재사용.
- DB: Oracle 23ai. DDL은 `ALTER TABLE` 마이그레이션.
- LLM 비용: 검증은 한 식당당 1회 단발(짧은 프롬프트). 500개 백필 시 약 500 호출.
- 봇/quota 제약 없음(OCI GenAI는 내부 호출).
- 기존 데이터: 약 500건 식당 → 백필 1회 필요. 신규 영상 처리 흐름에 자동 통합.
- 가정: LLM 판정 정확도 85-95%. 실수 시 어드민에서 수동 복구.
## 5. 아키텍처 개요
```
PipelineService.processExtract
│ (기존 흐름)
RestaurantService.upsert
RestaurantVerifyService.verifyAsync(restaurantId)
│ (비동기 — 사용자 응답 차단 안 함)
OciGenAiService.chat(prompt, maxTokens=100)
parseVerifyResponse → { valid: bool, isFranchise: bool, reason: string }
RestaurantMapper.updateVerification(id, hidden, hiddenReason, verifiedAt)
▼ (공개 조회 시)
RestaurantService.list(...) → WHERE hidden = 0
RestaurantController.adminList(includeHidden=true) → 전체 + hidden_reason 노출
```
I/O ↔ 순수 로직 경계: `parseVerifyResponse`는 순수 함수(LLM 응답 문자열 → 객체). 외부 I/O(LLM 호출, DB write)는 서비스 메서드.
## 6. 데이터 모델
### DB 마이그레이션
```sql
ALTER TABLE restaurants ADD (
hidden NUMBER(1) DEFAULT 0 NOT NULL,
hidden_reason VARCHAR2(120),
verified_at TIMESTAMP
);
CREATE INDEX idx_restaurants_hidden ON restaurants(hidden);
```
### Restaurant 도메인 추가 필드
| 필드 | 타입 | 의미 |
|------|------|------|
| `hidden` | `Boolean` | true면 공개 조회에서 제외 |
| `hiddenReason` | `String` | "not_restaurant" / "franchise" / "manual" / null |
| `verifiedAt` | `Instant` | 마지막 검증 시각, null이면 미검증 |
### LLM 응답 스키마
```json
{
"valid": true,
"is_franchise": false,
"reason": "한식 전문점, 로컬"
}
```
## 7. 함수 명세
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `RestaurantVerifyService.verify(id)` | 단건 검증 + DB 반영 | `void verify(String restaurantId)` | restaurantId | side-effect | LLM/DB 예외 → 로그 후 hidden 유지(공개) | **복잡** |
| `RestaurantVerifyService.verifyAsync(id)` | 비동기 트리거 | `void verifyAsync(String restaurantId)` | id | - | thread pool 만원 → 다음 cron 처리 | 단순 |
| `RestaurantVerifyService.verifyAll(limit)` | 백필(rate-limit 적용) | `int verifyAll(int batchSize)` | batch | 처리된 개수 | LLM rate limit → sleep | **복잡** |
| `RestaurantVerifyService.buildPrompt(r)` | 프롬프트 생성 | `String buildPrompt(Restaurant)` | r | 프롬프트 문자열 | - | 단순 |
| `RestaurantVerifyService.parseVerifyResponse(s)` | LLM 응답 → DTO | `VerifyResult parse(String)` | LLM raw | DTO | 파싱 실패 → valid=true 기본값(안전) | **복잡** |
| `RestaurantMapper.updateVerification(id, hidden, reason, ts)` | DB 갱신 | `int update(...)` | 4 args | 업데이트 행 수 | DB 예외 | 단순 |
| `RestaurantService.list()` (수정) | 공개 조회 hidden=0 필터 | `WHERE hidden = 0` 추가 | - | - | - | 단순 |
| `AdminRestaurantController.toggleHidden(id)` (신규) | 어드민 수동 토글 | `PATCH /api/admin/restaurants/{id}/hidden` | id, body | success | requireAdmin | 단순 |
| `AdminRestaurantController.verifyAll()` (신규) | 백필 트리거 | `POST /api/admin/restaurants/verify-all` | - | 처리 개수 | requireAdmin | 단순 |
> 복잡 함수는 각각 `fn-verify.md`, `fn-verify-all.md`, `fn-parse-verify-response.md` 후속 분리 가능(현재 후속 이슈로).
## 8. 흐름 / 알고리즘
### 신규 등록 검증
1. `PipelineService.processExtract` 완료 시 `restaurantId` 획득.
2. `RestaurantVerifyService.verifyAsync(restaurantId)` 호출(@Async).
3. 별도 스레드에서 `verify(id)` 실행:
- 식당 조회 → `buildPrompt``OciGenAiService.chat``parseVerifyResponse`
- `valid=false` 또는 `is_franchise=true`면 hidden=1, reason 설정
- `RestaurantMapper.updateVerification` 호출
4. 캐시 무효화는 검증 결과가 hidden=1일 때만(공개 목록 변경).
### 백필
1. 어드민 `POST /api/admin/restaurants/verify-all` 호출.
2. `verifyAll(batchSize=10)`:
- `WHERE verified_at IS NULL` 인 식당 10개 조회 → 순차 검증
- 식당당 200ms sleep(LLM rate limit 보호)
- 끝까지 반복(`do { ... } while (count == 10)`)
3. 전체 카운트 반환.
### 프롬프트
```
당신은 식당 데이터 큐레이터다. 다음 식당이 (1) 실제 운영 식당인지, (2) 흔한 프랜차이즈인지 판정하라.
식당명: {name}
주소: {address}
지역: {region}
음식 분류: {cuisineType}
언급된 음식: {foodsMentioned}
응답 형식(JSON만, 다른 텍스트 없이):
{"valid": true|false, "is_franchise": true|false, "reason": "20자 이내"}
가이드:
- valid=false: 식당 이름이 사람 이름, 영상 제목 일부, 일반 명사("점심", "맛집"), 영문 prefix("name:", "title:") 등 분명히 식당이 아닌 경우.
- is_franchise=true: 스타벅스, 맥도날드, 버거킹, 김밥천국, 본죽 등 전국 50개 이상 매장의 흔한 체인.
- 판단이 모호하면 valid=true, is_franchise=false (보수적).
```
## 9. 엣지케이스 & 에러 처리
- **LLM 응답이 JSON 아님**: `parseVerifyResponse`가 JSON 파싱 실패 → valid=true, is_franchise=false 기본값(안전).
- **LLM 호출 실패(timeout/quota)**: 로그 후 verified_at 미설정 → 다음 백필에서 재시도.
- **LLM이 false negative(잘못된 식당을 정상이라 판정)**: 어드민 수동 토글로 보완.
- **LLM이 false positive(정상 식당을 잘못/프랜차이즈로 판정)**: 어드민 수동 hidden=false 토글.
- **동시성**: verifyAsync가 같은 ID 두 번 호출돼도 idempotent(같은 결과로 update).
- **레이트 리밋**: 백필에서 식당당 200ms sleep + 단건 검증은 별 신경 안 씀(빈도 낮음).
## 10. 테스트 계획
- 단위:
- `parseVerifyResponse`: 정상 JSON / 파손 JSON / 빈 문자열 / 마크다운 코드블록 포함 케이스.
- `buildPrompt`: 모든 필드 채워진 경우 / 일부 null 케이스.
- 통합 (수동 또는 후속):
- 프랜차이즈 식당 1건 시드 → verifyAll → hidden=1 확인.
- 정상 식당 1건 시드 → verifyAll → hidden=0 확인.
- 회귀: 기존 `GET /api/restaurants` 응답 구조 변경 없음(필드만 추가, 옵션).
## 11. 리스크 & 대안 검토
- **선택**: 단발 LLM 판정 + 어드민 수동 보완.
- **대안 A**: 프랜차이즈 DB 자체 구축(스타벅스/맥도날드 등 화이트리스트 매칭) — 정확도↑이지만 운영 부담↑, 신규 프랜차이즈 누락 위험.
- **대안 B**: 추출 단계(OciGenAiService.parseJson)에서 한 번에 판정 — 비용↓이지만 추출 로직 비대해짐.
- **대안 C**: 이중 검증(LLM A + LLM B 일치 시만 hidden) — 정확도↑↑이지만 비용 2배.
- **트레이드오프**: 단발 판정은 비용·복잡도 낮으나 false positive 가능. 어드민 토글로 보완 가능하므로 수용.
## 12. 미해결 질문
- 백필 1회 트리거 후 주기적 재검증 필요한가(예: 폐업 식당 자동 hidden)? — 후속.
- LLM 비용 모니터링 — 별도 이슈로 분리 권고.
- 프랜차이즈 판정 임계값 — 사용자 의견 수렴 필요. 현재 가이드는 "전국 50개 이상".
- 어드민 UI에서 일괄 작업(체크박스 + 일괄 hidden 토글) — 별도 이슈.

View File

@@ -1470,12 +1470,12 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
setEditRest({
name: r.name,
cuisine_type: r.cuisine_type || "",
foods_mentioned: r.foods_mentioned.join(", "),
foods_mentioned: (r.foods_mentioned || []).join(", "),
evaluation: evalText,
address: r.address || "",
region: r.region || "",
price_range: r.price_range || "",
guests: r.guests.join(", "),
guests: (r.guests || []).join(", "),
});
} : undefined}
title={isAdmin ? "클릭하여 수정" : undefined}
@@ -1513,7 +1513,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
{r.cuisine_type && <p>: {r.cuisine_type}</p>}
{r.price_range && <p>: {r.price_range}</p>}
</div>
{r.foods_mentioned.length > 0 && (
{r.foods_mentioned?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{r.foods_mentioned.map((f, j) => (
<span key={j} className="px-1.5 py-0.5 bg-brand-50 text-brand-700 rounded text-xs">{f}</span>
@@ -1523,7 +1523,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
{r.evaluation?.text && (
<p className="mt-1 text-xs text-gray-600">{r.evaluation.text}</p>
)}
{r.guests.length > 0 && (
{r.guests?.length > 0 && (
<p className="mt-1 text-xs text-gray-500">: {r.guests.join(", ")}</p>
)}
</div>
@@ -1627,6 +1627,45 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
useEffect(() => { load(); }, [load]);
// #322/#323 LLM 검증 UI
const [verifyPending, setVerifyPending] = useState<number | null>(null);
const [verifying, setVerifying] = useState(false);
const [verifyResult, setVerifyResult] = useState<string | null>(null);
const loadVerifyPending = useCallback(() => {
api.getVerifyPending().then((r) => setVerifyPending(r.pending)).catch(() => setVerifyPending(null));
}, []);
useEffect(() => { loadVerifyPending(); }, [loadVerifyPending]);
const handleVerifyAll = async () => {
if (!isAdmin) return;
if (!confirm(`미검증 식당 ${verifyPending ?? "?"}건을 LLM으로 일괄 검증합니다.\n잘못 등록된 데이터/프랜차이즈를 자동으로 숨김 처리합니다.\n진행할까요?`)) return;
setVerifying(true);
setVerifyResult(null);
try {
const r = await api.verifyAll(10);
setVerifyResult(`${r.processed}건 검증 완료`);
loadVerifyPending();
load();
} catch (e) {
setVerifyResult(`실패: ${e instanceof Error ? e.message : String(e)}`);
} finally {
setVerifying(false);
}
};
const handleToggleHidden = async (r: Restaurant) => {
if (!isAdmin) return;
const becomingHidden = !r.hidden;
const reason = becomingHidden ? (prompt("숨김 사유(선택)", "manual") ?? "manual") : "";
try {
await api.setRestaurantHidden(r.id, becomingHidden, reason || "manual");
load();
} catch (e) {
alert(`실패: ${e instanceof Error ? e.message : String(e)}`);
}
};
const filtered = restaurants.filter((r) => {
if (nameSearch && !r.name.toLowerCase().includes(nameSearch.toLowerCase())) return false;
return true;
@@ -1728,6 +1767,18 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
)}
</div>
{isAdmin && (<>
{/* #322/#323 — LLM 검증 */}
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<span> {verifyPending ?? "?"}</span>
<button
onClick={handleVerifyAll}
disabled={verifying || verifyPending === 0}
className="px-3 py-1 text-xs rounded bg-amber-500 hover:bg-amber-600 text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
{verifying ? "검증 중..." : "LLM 검증"}
</button>
{verifyResult && <span className="text-amber-600">{verifyResult}</span>}
</div>
<button
onClick={async () => {
const pending = await fetch(`/api/restaurants/tabling-pending`, {
@@ -1890,6 +1941,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("price_range")}>{sortIcon("price_range")}</th>
<th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("rating")}>{sortIcon("rating")}</th>
<th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("business_status")}>{sortIcon("business_status")}</th>
<th className="text-center px-4 py-3"></th>
</tr>
</thead>
<tbody>
@@ -1917,11 +1969,34 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
<span className="text-xs text-gray-400">-</span>
)}
</td>
<td className="px-4 py-3 text-center" onClick={(e) => e.stopPropagation()}>
{r.hidden ? (
<button
onClick={() => handleToggleHidden(r)}
disabled={!isAdmin}
title={r.hidden_reason || "manual"}
className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold hover:bg-red-200 disabled:opacity-50"
>
{r.hidden_reason ? `(${r.hidden_reason.slice(0, 12)})` : ""}
</button>
) : r.verified_at ? (
<button
onClick={() => handleToggleHidden(r)}
disabled={!isAdmin}
title="검증 통과 — 클릭하면 숨김"
className="px-1.5 py-0.5 bg-green-100 text-green-700 rounded text-[10px] font-semibold hover:bg-green-200 disabled:opacity-50"
>
OK
</button>
) : (
<span className="text-xs text-gray-400"></span>
)}
</td>
</tr>
))}
{!loading && filtered.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
@@ -2144,6 +2219,7 @@ interface AdminUser {
email: string | null;
nickname: string | null;
avatar_url: string | null;
is_admin: boolean;
provider: string | null;
created_at: string | null;
favorite_count: number;
@@ -2246,6 +2322,7 @@ function UsersPanel() {
<tr>
<th className="text-left px-4 py-2"></th>
<th className="text-left px-4 py-2"></th>
<th className="text-center px-4 py-2"></th>
<th className="text-center px-4 py-2"></th>
<th className="text-center px-4 py-2"></th>
<th className="text-center px-4 py-2"></th>
@@ -2282,6 +2359,27 @@ function UsersPanel() {
</div>
</td>
<td className="px-4 py-2 text-gray-500">{u.email || "-"}</td>
<td className="px-4 py-2 text-center">
<button
onClick={async (e) => {
e.stopPropagation();
try {
await api.updateAdminUserAdmin(u.id, !u.is_admin);
setUsers((prev) => prev.map((x) => x.id === u.id ? { ...x, is_admin: !u.is_admin } : x));
} catch (err) {
console.error("Failed to update admin:", err);
alert("관리자 권한 변경에 실패했습니다.");
}
}}
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium transition-colors ${
u.is_admin
? "bg-green-100 text-green-700 hover:bg-green-200"
: "bg-gray-100 text-gray-400 hover:bg-gray-200"
}`}
>
{u.is_admin ? "ON" : "OFF"}
</button>
</td>
<td className="px-4 py-2 text-center">
{u.favorite_count > 0 ? (
<span className="inline-block px-2 py-0.5 bg-red-50 text-red-600 rounded-full text-xs font-medium">

View File

@@ -0,0 +1,52 @@
// #304 어드민 페이지 공통 유틸.
// 결함: localStorage 직접 접근 10+곳 / SSE 파싱 코드 6곳 중복.
const TOKEN_KEY = "tasteby_token";
export function getAdminToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(TOKEN_KEY);
}
export function authHeaders(): Record<string, string> {
const token = getAdminToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
/**
* SSE(Server-Sent Events) 스트림을 라인 단위로 파싱하여 onEvent 콜백을 호출.
* 호환 패턴: `data: { ...json... }` 한 줄 = 한 이벤트.
* 비어있는 줄은 무시. JSON 파싱 실패 시 콜백 skip.
*/
export async function consumeSseStream(
response: Response,
onEvent: (event: unknown) => void,
onError?: (err: unknown) => void,
): Promise<void> {
const reader = response.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
if (!payload) continue;
try {
onEvent(JSON.parse(payload));
} catch {
// 무시: 일부 SSE 줄이 JSON이 아닐 수도 있음
}
}
}
} catch (err) {
onError?.(err);
}
}

View File

@@ -51,6 +51,10 @@ export interface Restaurant {
website: string | null;
channels?: string[];
foods_mentioned?: string[];
// #322 LLM 검증
hidden?: boolean;
hidden_reason?: string | null;
verified_at?: string | null;
}
export interface VideoLink {
@@ -310,6 +314,7 @@ export const api = {
email: string | null;
nickname: string | null;
avatar_url: string | null;
is_admin: boolean;
provider: string | null;
created_at: string | null;
favorite_count: number;
@@ -320,6 +325,14 @@ export const api = {
}>(`/api/admin/users${qs ? `?${qs}` : ""}`);
},
updateAdminUserAdmin(userId: string, admin: boolean) {
return fetchApi<{ success: boolean }>(`/api/admin/users/${userId}/admin`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ admin }),
});
},
getAdminUserFavorites(userId: string) {
return fetchApi<
{
@@ -567,4 +580,30 @@ export const api = {
{ method: "POST" }
);
},
// #322 — LLM 검증 어드민 API
getVerifyPending() {
return fetchApi<{ pending: number }>("/api/admin/restaurants/verify/pending");
},
verifyAll(batchSize: number = 10) {
return fetchApi<{ processed: number }>(
`/api/admin/restaurants/verify/all?batchSize=${batchSize}`,
{ method: "POST" }
);
},
verifyOne(id: string) {
return fetchApi<{ success: boolean; id: string }>(
`/api/admin/restaurants/${id}/verify`,
{ method: "POST" }
);
},
setRestaurantHidden(id: string, hidden: boolean, reason: string = "manual") {
return fetchApi<{ success: boolean; id: string; hidden: boolean }>(
`/api/admin/restaurants/${id}/hidden`,
{
method: "PATCH",
body: JSON.stringify({ hidden, reason }),
}
);
},
};