Files
tasteby/backend-java/src/main/java/com/tasteby/service/ExtractorService.java
joungmin 4407f2d67d fix(pipeline): #291+#292 운영 영향 큰 결함 6건 일괄 수정
#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
2026-06-15 13:21:25 +09:00

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(), "");
}
}
}