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} + + + + + + + +