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
150 lines
6.2 KiB
Java
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"); }
|
|
}
|
|
}
|