#292: - ExtractorService.extractRestaurants: transcript null/blank 가드 (NPE 방지) - PipelineService.processExtract: 진입 시 status='processing' 명시 전이 - PipelineService.processExtract: geocode 실패(geo==null) 시 좌표/place_id/주소 관련 컬럼을 data에 put하지 않아 upsert COALESCE 보존 의도 명확화 - GeocodingService.parseRegionFromAddress: 빈 토큰을 region 문자열에 포함하지 않도록 정규화 (예: '한국||구' 같은 깨진 토큰 방지) #291: - VideoService.saveVideosBatch: @Transactional 추가 → batch insert 원자성 - .gitignore: backend-java/cookies.txt 및 **/cookies.txt 명시 (보안 노출 차단) 후속 분리: #325 (#291 잔여 MINOR), #326 (#292 parseJson 최적화 + MINOR) Refs: #291 #292
93 lines
3.7 KiB
Java
93 lines
3.7 KiB
Java
package com.tasteby.service;
|
|
|
|
import com.tasteby.util.CuisineTypes;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
import org.springframework.stereotype.Service;
|
|
|
|
import java.util.Collections;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
|
|
@Service
|
|
public class ExtractorService {
|
|
|
|
private static final Logger log = LoggerFactory.getLogger(ExtractorService.class);
|
|
|
|
private final OciGenAiService genAi;
|
|
|
|
private static final String EXTRACT_PROMPT = """
|
|
다음은 유튜브 먹방/맛집 영상의 자막입니다.
|
|
이 영상에서 언급된 모든 식당 정보를 추출하세요.
|
|
|
|
규칙:
|
|
- 식당이 없으면 빈 배열 [] 반환
|
|
- 각 식당에 대해 아래 필드를 JSON 배열로 반환
|
|
- 확실하지 않은 정보는 null
|
|
- 추가 설명 없이 JSON만 반환
|
|
- 무조건 한글로 만들어주세요
|
|
|
|
필드:
|
|
- name: 식당 이름 (string, 필수)
|
|
- address: 주소 또는 위치 힌트 (string | null)
|
|
- region: 지역을 "나라|시/도|구/군/시" 파이프(|) 구분 형식으로 작성 (string | null)
|
|
- 한국 예시: "한국|서울|강남구", "한국|부산|해운대구", "한국|제주", "한국|강원|강릉시"
|
|
- 해외 예시: "일본|도쿄", "일본|오사카", "싱가포르", "미국|뉴욕", "태국|방콕"
|
|
- 나라는 한글로, 해외 도시도 한글로 표기
|
|
- cuisine_type: 아래 목록에서 가장 적합한 것을 선택 (string, 필수). 반드시 아래 목록 중 하나를 사용:
|
|
%s
|
|
- price_range: 가격대 (예: 1만원대, 2-3만원) (string | null)
|
|
- foods_mentioned: 언급된 대표 메뉴 (string[], 최대 10개, 우선순위 높은 순, 반드시 한글로 작성)
|
|
- evaluation: 평가 내용을 100자 이내로 요약 (string | null)
|
|
- guests: 함께한 게스트 (string[])
|
|
|
|
영상 제목: {title}
|
|
자막:
|
|
{transcript}
|
|
|
|
JSON 배열:""".formatted(CuisineTypes.CUISINE_LIST_TEXT);
|
|
|
|
public ExtractorService(OciGenAiService genAi) {
|
|
this.genAi = genAi;
|
|
}
|
|
|
|
public String getPrompt() {
|
|
return EXTRACT_PROMPT;
|
|
}
|
|
|
|
public record ExtractionResult(List<Map<String, Object>> restaurants, String rawResponse) {}
|
|
|
|
/**
|
|
* Extract restaurant info from a video transcript using LLM.
|
|
*/
|
|
@SuppressWarnings("unchecked")
|
|
public ExtractionResult extractRestaurants(String title, String transcript, String customPrompt) {
|
|
// #292 — transcript null/blank 가드 (NPE 방지)
|
|
if (transcript == null || transcript.isBlank()) {
|
|
return new ExtractionResult(List.of(), "");
|
|
}
|
|
// Truncate very long transcripts
|
|
if (transcript.length() > 8000) {
|
|
transcript = transcript.substring(0, 7000) + "\n...(중략)...\n" + transcript.substring(transcript.length() - 1000);
|
|
}
|
|
|
|
String template = customPrompt != null ? customPrompt : EXTRACT_PROMPT;
|
|
String prompt = template.replace("{title}", title).replace("{transcript}", transcript);
|
|
|
|
try {
|
|
String raw = genAi.chat(prompt, 8192);
|
|
Object result = genAi.parseJson(raw);
|
|
if (result instanceof List<?> list) {
|
|
return new ExtractionResult((List<Map<String, Object>>) list, raw);
|
|
}
|
|
if (result instanceof Map<?, ?> map) {
|
|
return new ExtractionResult(List.of((Map<String, Object>) map), raw);
|
|
}
|
|
return new ExtractionResult(Collections.emptyList(), raw);
|
|
} catch (Exception e) {
|
|
log.error("Restaurant extraction failed: {}", e.getMessage());
|
|
return new ExtractionResult(Collections.emptyList(), "");
|
|
}
|
|
}
|
|
}
|