From 47020fd649d5bf7f61753f495bbf391319307ea3 Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 15 Jun 2026 19:38:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20#356=20=EC=98=81=EC=83=81-?= =?UTF-8?q?=EC=8B=9D=EB=8B=B9=20=EA=B4=80=EB=A0=A8=EB=8F=84=20LLM=20?= =?UTF-8?q?=ED=8F=89=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB V20260615b: video_restaurants.{relevance, relevance_reason, relevance_evaluated_at} + idx_vr_relevance - VideoRelevanceService (#322 패턴): @Async verifyAsync + verify + verifyAll(batchSize) - PipelineService.processExtract → linkVideoRestaurant 후 verifyAsync(linkId) 자동 트리거 - GET /api/restaurants/{id}/videos: 기본 strong/unknown만, ?include_weak=true 시 모두 + relevance/reason - AdminVideoRelevanceController: GET pending / POST all / POST {id}/evaluate / PATCH {id} - 캐시 키 strong|all 분리, LLM 실패 시 unknown 안전 기본값(표시 유지) Refs: #356 (close) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 11 ++ .../AdminVideoRelevanceController.java | 68 +++++++++ .../controller/RestaurantController.java | 8 +- .../com/tasteby/mapper/RestaurantMapper.java | 14 +- .../com/tasteby/service/PipelineService.java | 10 +- .../tasteby/service/RestaurantService.java | 29 +++- .../service/VideoRelevanceService.java | 144 ++++++++++++++++++ .../migration/V20260615b__video_relevance.sql | 7 + .../mybatis/mapper/RestaurantMapper.xml | 40 ++++- 9 files changed, 322 insertions(+), 9 deletions(-) create mode 100644 backend-java/src/main/java/com/tasteby/controller/AdminVideoRelevanceController.java create mode 100644 backend-java/src/main/java/com/tasteby/service/VideoRelevanceService.java create mode 100644 backend-java/src/main/resources/db/migration/V20260615b__video_relevance.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index d9efb1c..f4ae011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ ## 2026-06-15 +### 🎯 #356 영상-식당 관련도 LLM 평가 (v0.1.43) +- DB: video_restaurants 컬럼 추가 (relevance/relevance_reason/relevance_evaluated_at) + idx_vr_relevance +- VideoRelevanceService 신규 (#322 RestaurantVerifyService 패턴 모방, @Async verifyAsync/verify/verifyAll) +- PipelineService.processExtract — linkVideoRestaurant 후 verifyAsync(linkId) 자동 트리거 +- GET /api/restaurants/{id}/videos: 기본 strong/unknown만 응답 (안전 기본값), ?include_weak=true 시 모두 +- AdminVideoRelevanceController 신규 (pending/all/{id}/evaluate/{id} PATCH) +- 응답 매핑: relevance, relevance_reason 필드 동봉 +- 기존 1244 링크는 'unknown' 시작 → 어드민 백필로 점진 평가 +- 설계서: docs/design/356-video-relevance-llm/README.md +- Refs: #356 (close) + ### 🧹 #351 admin SSE 6곳 consumeSseStream 통일 (v0.1.42) - VideosPanel 4곳(bulkTranscript/Extract, rebuildVectors, remapCuisine, remapFoods) - RestaurantsPanel 2곳(bulkTabling, bulkCatchtable) diff --git a/backend-java/src/main/java/com/tasteby/controller/AdminVideoRelevanceController.java b/backend-java/src/main/java/com/tasteby/controller/AdminVideoRelevanceController.java new file mode 100644 index 0000000..8349ef6 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/controller/AdminVideoRelevanceController.java @@ -0,0 +1,68 @@ +package com.tasteby.controller; + +import com.tasteby.security.AuthUtil; +import com.tasteby.service.RestaurantService; +import com.tasteby.service.VideoRelevanceService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; +import java.util.Set; + +/** + * #356 영상-식당 관련도 LLM 평가 어드민 API. + * - 미평가 카운트 / 일괄 백필 / 단건 재평가 / 수동 토글 + */ +@RestController +@RequestMapping("/api/admin/video-relevance") +public class AdminVideoRelevanceController { + + private static final Logger log = LoggerFactory.getLogger(AdminVideoRelevanceController.class); + private static final Set VALID = Set.of("strong", "weak", "incidental", "unknown"); + + private final RestaurantService restaurantService; + private final VideoRelevanceService relevanceService; + + public AdminVideoRelevanceController(RestaurantService restaurantService, VideoRelevanceService relevanceService) { + this.restaurantService = restaurantService; + this.relevanceService = relevanceService; + } + + @GetMapping("/pending") + public Map pendingCount() { + var admin = AuthUtil.requireAdmin(); + int n = restaurantService.countUnevaluatedLinks(); + log.info("[ADMIN] {} video-relevance pending: {}", admin.getSubject(), n); + return Map.of("pending", n); + } + + @PostMapping("/all") + public Map verifyAll(@RequestParam(defaultValue = "10") int batchSize) { + var admin = AuthUtil.requireAdmin(); + log.info("[ADMIN] {} triggered video-relevance verifyAll(batchSize={})", admin.getSubject(), batchSize); + int processed = relevanceService.verifyAll(batchSize); + return Map.of("processed", processed); + } + + @PostMapping("/{linkId}/evaluate") + public Map evaluateOne(@PathVariable String linkId) { + var admin = AuthUtil.requireAdmin(); + log.info("[ADMIN] {} video-relevance evaluate({})", admin.getSubject(), linkId); + relevanceService.verify(linkId); + return Map.of("success", true, "linkId", linkId); + } + + @PatchMapping("/{linkId}") + public Map setRelevance(@PathVariable String linkId, @RequestBody Map body) { + var admin = AuthUtil.requireAdmin(); + Object relObj = body.get("relevance"); + if (!(relObj instanceof String relevance) || !VALID.contains(relevance)) { + return Map.of("success", false, "error", "relevance must be one of strong|weak|incidental|unknown"); + } + String reason = body.get("reason") instanceof String s ? s : "manual"; + restaurantService.updateLinkRelevance(linkId, relevance, reason); + log.info("[ADMIN] {} manual relevance={} for link {}", admin.getSubject(), relevance, linkId); + return Map.of("success", true, "linkId", linkId, "relevance", relevance); + } +} diff --git a/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java b/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java index e45e3b4..cabc8a1 100644 --- a/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java +++ b/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java @@ -413,8 +413,10 @@ public class RestaurantController { } @GetMapping("/{id}/videos") - public List> videos(@PathVariable String id) { - String key = cache.makeKey("restaurant_videos", id); + public List> videos( + @PathVariable String id, + @RequestParam(name = "include_weak", defaultValue = "false") boolean includeWeak) { + String key = cache.makeKey("restaurant_videos", id, includeWeak ? "all" : "strong"); String cached = cache.getRaw(key); if (cached != null) { try { @@ -423,7 +425,7 @@ public class RestaurantController { } var r = restaurantService.findById(id); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found"); - var result = restaurantService.findVideoLinks(id); + var result = restaurantService.findVideoLinks(id, includeWeak); cache.set(key, result); return result; } diff --git a/backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java b/backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java index 2177ee7..be6eae4 100644 --- a/backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java +++ b/backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java @@ -28,9 +28,21 @@ public interface RestaurantMapper { int countUnverified(); + // #356 영상-식당 관련도 + void updateLinkRelevance(@Param("linkId") String linkId, + @Param("relevance") String relevance, + @Param("reason") String reason); + + Map findLinkContext(@Param("linkId") String linkId); + + List> findUnevaluatedLinks(@Param("limit") int limit); + + int countUnevaluatedLinks(); + Restaurant findById(@Param("id") String id); - List> findVideoLinks(@Param("restaurantId") String restaurantId); + List> findVideoLinks(@Param("restaurantId") String restaurantId, + @Param("includeWeak") boolean includeWeak); void insertRestaurant(Restaurant r); diff --git a/backend-java/src/main/java/com/tasteby/service/PipelineService.java b/backend-java/src/main/java/com/tasteby/service/PipelineService.java index 28f8359..6b844a0 100644 --- a/backend-java/src/main/java/com/tasteby/service/PipelineService.java +++ b/backend-java/src/main/java/com/tasteby/service/PipelineService.java @@ -29,6 +29,7 @@ public class PipelineService { private final VectorService vectorService; private final CacheService cacheService; private final RestaurantVerifyService verifyService; + private final VideoRelevanceService relevanceService; public PipelineService(YouTubeService youTubeService, ExtractorService extractorService, @@ -37,7 +38,8 @@ public class PipelineService { VideoService videoService, VectorService vectorService, CacheService cacheService, - RestaurantVerifyService verifyService) { + RestaurantVerifyService verifyService, + VideoRelevanceService relevanceService) { this.youTubeService = youTubeService; this.extractorService = extractorService; this.geocodingService = geocodingService; @@ -46,6 +48,7 @@ public class PipelineService { this.vectorService = vectorService; this.cacheService = cacheService; this.verifyService = verifyService; + this.relevanceService = relevanceService; } /** @@ -145,13 +148,16 @@ public class PipelineService { evaluationJson = JsonUtil.toJson(s); } - restaurantService.linkVideoRestaurant( + String linkId = restaurantService.linkVideoRestaurant( videoDbId, restId, foods instanceof List ? (List) foods : null, evaluationJson, guests instanceof List ? (List) guests : null ); + // #356 — 신규 등록 직후 비동기 관련도 평가 + relevanceService.verifyAsync(linkId); + // Vector embeddings var chunks = VectorService.buildChunks(name, restData, title); if (!chunks.isEmpty()) { diff --git a/backend-java/src/main/java/com/tasteby/service/RestaurantService.java b/backend-java/src/main/java/com/tasteby/service/RestaurantService.java index d601cc3..8448e5d 100644 --- a/backend-java/src/main/java/com/tasteby/service/RestaurantService.java +++ b/backend-java/src/main/java/com/tasteby/service/RestaurantService.java @@ -77,7 +77,11 @@ public class RestaurantService { } public List> findVideoLinks(String restaurantId) { - var rows = mapper.findVideoLinks(restaurantId); + return findVideoLinks(restaurantId, false); + } + + public List> findVideoLinks(String restaurantId, boolean includeWeak) { + var rows = mapper.findVideoLinks(restaurantId, includeWeak); return rows.stream().map(row -> { var m = JsonUtil.lowerKeys(row); m.put("foods_mentioned", JsonUtil.parseStringList(m.get("foods_mentioned"))); @@ -87,6 +91,26 @@ public class RestaurantService { }).toList(); } + // #356 영상-식당 관련도 + public void updateLinkRelevance(String linkId, String relevance, String reason) { + mapper.updateLinkRelevance(linkId, relevance, reason); + } + + public Map findLinkContext(String linkId) { + var row = mapper.findLinkContext(linkId); + return row != null ? JsonUtil.lowerKeys(row) : null; + } + + public List> findUnevaluatedLinks(int limit) { + return mapper.findUnevaluatedLinks(limit).stream() + .map(JsonUtil::lowerKeys) + .toList(); + } + + public int countUnevaluatedLinks() { + return mapper.countUnevaluatedLinks(); + } + public void update(String id, Map fields) { mapper.updateFields(id, fields); } @@ -138,12 +162,13 @@ public class RestaurantService { } } - public void linkVideoRestaurant(String videoId, String restaurantId, List foods, String evaluation, List guests) { + public String linkVideoRestaurant(String videoId, String restaurantId, List foods, String evaluation, List guests) { String id = IdGenerator.newId(); String foodsJson = foods != null ? JsonUtil.toJson(foods) : null; String guestsJson = guests != null ? JsonUtil.toJson(guests) : null; String evalJson = JsonUtil.normalizeEvaluation(evaluation); mapper.linkVideoRestaurant(id, videoId, restaurantId, foodsJson, evalJson, guestsJson); + return id; } public void updateCuisineType(String id, String cuisineType) { diff --git a/backend-java/src/main/java/com/tasteby/service/VideoRelevanceService.java b/backend-java/src/main/java/com/tasteby/service/VideoRelevanceService.java new file mode 100644 index 0000000..3893e4f --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/service/VideoRelevanceService.java @@ -0,0 +1,144 @@ +package com.tasteby.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * #356 영상-식당 관련도 LLM 평가. + * 설계서: docs/design/356-video-relevance-llm/README.md + * + * 신규 등록 시 자동 평가 + 어드민 백필. 결과는 video_restaurants.relevance에 저장. + * - strong: 본격 다룸 (방문 리뷰, 메뉴 평가) + * - weak: 잠깐 언급, 비교 대상 + * - incidental: 일반 토픽 중 단순 언급, 입점 전 + * - unknown: 미평가 or LLM 실패 (안전 기본값으로 표시 유지) + */ +@Service +public class VideoRelevanceService { + + private static final Logger log = LoggerFactory.getLogger(VideoRelevanceService.class); + private static final Set VALID = Set.of("strong", "weak", "incidental", "unknown"); + private static final long BACKFILL_SLEEP_MS = 200; + + private final RestaurantService restaurantService; + private final OciGenAiService genAi; + private final ObjectMapper jsonMapper = new ObjectMapper(); + + public VideoRelevanceService(RestaurantService restaurantService, OciGenAiService genAi) { + this.restaurantService = restaurantService; + this.genAi = genAi; + } + + @Async + public void verifyAsync(String linkId) { + try { + verify(linkId); + } catch (Exception e) { + log.warn("verifyAsync failed for link {}: {}", linkId, e.getMessage()); + } + } + + public void verify(String linkId) { + Map ctx = restaurantService.findLinkContext(linkId); + if (ctx == null) return; + VerifyResult result; + try { + String prompt = buildPrompt(ctx); + String response = genAi.chat(prompt, 120); + result = parseRelevance(response); + } catch (Exception e) { + log.warn("verify({}) LLM failed: {} — keeping unknown", linkId, e.getMessage()); + return; + } + restaurantService.updateLinkRelevance(linkId, result.relevance(), truncate(result.reason(), 120)); + } + + public int verifyAll(int batchSize) { + int total = 0; + List> batch; + while (!(batch = restaurantService.findUnevaluatedLinks(batchSize)).isEmpty()) { + for (Map row : batch) { + String linkId = (String) row.get("link_id"); + if (linkId == null) continue; + try { + verify(linkId); + } catch (Exception e) { + log.warn("verifyAll({}) failed: {}", linkId, 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 ---- + + String buildPrompt(Map ctx) { + String foods = safeStr(ctx.get("foods_mentioned")); + String evaluation = safeStr(ctx.get("evaluation")); + return "다음 YouTube 영상이 이 식당을 어떻게 다루는지 판정하라.\n\n" + + "식당명: " + safeStr(ctx.get("restaurant_name")) + "\n" + + "주소: " + safeStr(ctx.get("address")) + "\n" + + "음식 분류: " + safeStr(ctx.get("cuisine_type")) + "\n" + + "언급된 음식: " + (foods.isEmpty() ? "(없음)" : foods) + "\n\n" + + "영상 제목: " + safeStr(ctx.get("video_title")) + "\n" + + "영상 채널: " + safeStr(ctx.get("channel_name")) + "\n" + + "영상에 등장한 평가: " + (evaluation.isEmpty() ? "(없음)" : evaluation) + "\n\n" + + "응답 형식(JSON만, 다른 텍스트 없이):\n" + + "{\"relevance\": \"strong\"|\"weak\"|\"incidental\", \"reason\": \"20자 이내 한국어\"}\n\n" + + "가이드:\n" + + "- strong: 영상이 이 식당을 본격 다룸 (방문 리뷰, 메뉴 평가).\n" + + "- weak: 잠깐 언급, 다른 식당과 비교 대상으로 등장.\n" + + "- incidental: 일반 토픽 중 단순 언급, 식당 입점 전 영상.\n" + + "- 판단 모호 시 strong (보수적 — 사용자에게 표시 유지)."; + } + + private static final Pattern JSON_BLOCK = Pattern.compile("\\{[^{}]*\\}", Pattern.DOTALL); + + VerifyResult parseRelevance(String raw) { + if (raw == null) return VerifyResult.unknown(); + String trimmed = raw.trim(); + String json = (trimmed.startsWith("{") && trimmed.endsWith("}")) ? trimmed : null; + if (json == null) { + Matcher m = JSON_BLOCK.matcher(raw); + if (m.find()) json = m.group(); + } + if (json == null) return VerifyResult.unknown(); + try { + JsonNode node = jsonMapper.readTree(json); + String rel = node.path("relevance").asText("unknown").toLowerCase(); + if (!VALID.contains(rel)) rel = "unknown"; + String reason = node.path("reason").asText(""); + return new VerifyResult(rel, reason); + } catch (Exception e) { + return VerifyResult.unknown(); + } + } + + private static String safeStr(Object o) { + return o == null ? "" : o.toString(); + } + + private static String truncate(String s, int max) { + return s == null ? null : (s.length() <= max ? s : s.substring(0, max)); + } + + public record VerifyResult(String relevance, String reason) { + public static VerifyResult unknown() { return new VerifyResult("unknown", "parse_failed"); } + } +} diff --git a/backend-java/src/main/resources/db/migration/V20260615b__video_relevance.sql b/backend-java/src/main/resources/db/migration/V20260615b__video_relevance.sql new file mode 100644 index 0000000..18579f7 --- /dev/null +++ b/backend-java/src/main/resources/db/migration/V20260615b__video_relevance.sql @@ -0,0 +1,7 @@ +-- #356 영상-식당 관련도 LLM 평가 +ALTER TABLE video_restaurants ADD ( + relevance VARCHAR2(16) DEFAULT 'unknown' NOT NULL, + relevance_reason VARCHAR2(120), + relevance_evaluated_at TIMESTAMP +); +CREATE INDEX idx_vr_relevance ON video_restaurants(relevance); diff --git a/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml b/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml index 2499353..afef3bb 100644 --- a/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml +++ b/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml @@ -69,14 +69,20 @@ @@ -315,4 +321,36 @@ SELECT COUNT(*) FROM restaurants WHERE verified_at IS NULL + + + + UPDATE video_restaurants + SET relevance = #{relevance}, + relevance_reason = #{reason,jdbcType=VARCHAR}, + relevance_evaluated_at = CURRENT_TIMESTAMP + WHERE id = #{linkId} + + + + + + + +