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
This commit is contained in:
joungmin
2026-06-15 13:04:23 +09:00
parent d3cd1b5d5f
commit d2e78b0363
9 changed files with 516 additions and 5 deletions

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

@@ -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

@@ -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;
}
/**
@@ -150,6 +153,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

@@ -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>