"""LLM-based restaurant info extraction from video transcripts. Uses OCI GenAI (Gemini Flash) to extract structured restaurant data. """ from __future__ import annotations import json import logging import os import re import oci from oci.generative_ai_inference import GenerativeAiInferenceClient from oci.generative_ai_inference.models import ( ChatDetails, GenericChatRequest, OnDemandServingMode, TextContent, UserMessage, ) from core.cuisine import CUISINE_LIST_TEXT logger = logging.getLogger(__name__) def _get_client() -> GenerativeAiInferenceClient: config = oci.config.from_file() endpoint = os.environ.get("OCI_CHAT_ENDPOINT") or os.environ["OCI_GENAI_ENDPOINT"] return GenerativeAiInferenceClient(config, service_endpoint=endpoint) def _llm(prompt: str, max_tokens: int = 4096) -> str: client = _get_client() req = GenericChatRequest( messages=[UserMessage(content=[TextContent(text=prompt)])], max_tokens=max_tokens, temperature=0, ) det = ChatDetails( compartment_id=os.environ["OCI_COMPARTMENT_ID"], serving_mode=OnDemandServingMode(model_id=os.environ["OCI_CHAT_MODEL_ID"]), chat_request=req, ) resp = client.chat(det) return resp.data.chat_response.choices[0].message.content[0].text.strip() def _parse_json(raw: str) -> dict | list: raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.MULTILINE).strip() raw = re.sub(r",\s*([}\]])", r"\1", raw) try: return json.loads(raw) except json.JSONDecodeError: pass try: return json.JSONDecoder(strict=False).decode(raw) except json.JSONDecodeError: pass # recover truncated array — extract complete objects one by one if raw.lstrip().startswith("["): decoder = json.JSONDecoder(strict=False) items: list = [] idx = raw.index("[") + 1 while idx < len(raw): while idx < len(raw) and raw[idx] in " \t\n\r,": idx += 1 if idx >= len(raw) or raw[idx] == "]": break try: obj, end = decoder.raw_decode(raw, idx) items.append(obj) idx = end except json.JSONDecodeError: # Try to recover truncated last object by closing braces remainder = raw[idx:] for fix in ["}", "}]", '"}', '"}' , '"}]', "null}", "null}]"]: try: patched = remainder.rstrip().rstrip(",") + fix obj = json.loads(patched) if isinstance(obj, dict) and obj.get("name"): items.append(obj) except (json.JSONDecodeError, ValueError): continue break if items: logger.info("Recovered %d restaurants from truncated JSON", len(items)) return items raise ValueError(f"JSON parse failed: {raw[:80]!r}") _EXTRACT_PROMPT = """\ 다음은 유튜브 먹방/맛집 영상의 자막입니다. 이 영상에서 언급된 모든 식당 정보를 추출하세요. 규칙: - 식당이 없으면 빈 배열 [] 반환 - 각 식당에 대해 아래 필드를 JSON 배열로 반환 - 확실하지 않은 정보는 null - 추가 설명 없이 JSON만 반환 필드: - name: 식당 이름 (string, 필수) - address: 주소 또는 위치 힌트 (string | null) - region: 지역을 "나라|시/도|구/군/시" 파이프(|) 구분 형식으로 작성 (string | null) - 한국 예시: "한국|서울|강남구", "한국|부산|해운대구", "한국|제주", "한국|강원|강릉시" - 해외 예시: "일본|도쿄", "일본|오사카", "싱가포르", "미국|뉴욕", "태국|방콕" - 나라는 한글로, 해외 도시도 한글로 표기 - cuisine_type: 아래 목록에서 가장 적합한 것을 선택 (string, 필수). 반드시 아래 목록 중 하나를 사용: {cuisine_types} - price_range: 가격대 (예: 1만원대, 2-3만원) (string | null) - foods_mentioned: 언급된 대표 메뉴 (string[], 최대 10개, 우선순위 높은 순, 반드시 한글로 작성) - evaluation: 평가 내용 (string | null) - guests: 함께한 게스트 (string[]) 영상 제목: {{title}} 자막: {{transcript}} JSON 배열:""".format(cuisine_types=CUISINE_LIST_TEXT) def extract_restaurants(title: str, transcript: str, custom_prompt: str | None = None) -> tuple[list[dict], str]: """Extract restaurant info from a video transcript using LLM. Returns (list of restaurant dicts, raw LLM response text). """ # Truncate very long transcripts if len(transcript) > 8000: transcript = transcript[:7000] + "\n...(중략)...\n" + transcript[-1000:] template = custom_prompt if custom_prompt else _EXTRACT_PROMPT prompt = template.format(title=title, transcript=transcript) try: raw = _llm(prompt, max_tokens=8192) result = _parse_json(raw) if isinstance(result, list): return result, raw if isinstance(result, dict): return [result], raw return [], raw except Exception as e: logger.error("Restaurant extraction failed: %s", e) return [], ""