Files
tasteby/backend-java/src/main/java/com/tasteby/service/RestaurantVerifyService.java
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

150 lines
6.2 KiB
Java

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"); }
}
}