From dbae2d4f355f06f37f5f53a85f770ea604e7a487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Wed, 16 Sep 2026 17:30:11 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/backend:=20LLM=20=EA=B3=B5?= =?UTF-8?q?=EA=B8=89=EC=9E=90=EB=A5=BC=20OpenAI=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98,=20Perplexity?= =?UTF-8?q?=20=EC=8B=A4=EB=B9=84=EC=9A=A9=20=EA=B3=84=EC=B8=A1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 1. Gemini -> OpenAI 공급자 추상화 Gemini 쿼터/인증 실패로 COPY 잡(소개문·FAQ 생성)이 반복 DEAD 되는 걸 보고, 공급자를 OpenAI로 바꾸되 설정 하나로 되돌릴 수 있게 했다. - services/llm/errors.py·types.py(신규): 공급자 무관 예외·Usage·ImagePart·LlmResult - services/llm/gemini.py: 기존 call() 은 그대로 두고 generate() 인터페이스 추가 - services/llm/openai.py(신규): OpenAI Chat Completions 구현. 실측(2026-09-16): gpt-5.6-luna 는 temperature 커스텀 값을 거부한다("Only the default (1) value is supported") — 아예 안 보낸다. - services/llm/provider.py(신규): LLM_PROVIDER 설정(기본 openai, 모르는 값은 gemini)으로 둘 중 하나를 고른다. - gemini_text.py·gemini.py(vision)·gemini_extract.py: 공개 함수 이름은 그대로 두고 내부만 provider.active() 로 배선 — vision_service.py 등 6개 호출부는 무변경. 단 model 선택 로직(vision_service.py·copy_steps.py)은 공급자에 맞는 모델명을 고르도록 한 줄씩 고쳤다. - config_models.py: llm_provider·openai_api_key·openai_text_model·openai_vision_model 추가. ## 2. Perplexity 실비용 계측 추가 OpenAI 전환 김에 실제 발행 파이프라인(스테이,머뭄 기준)을 끝까지 돌려 LLM 비용을 재보니, services/llm/perplexity.py 에는 애초에 토큰·비용 계측이 없었다. 추가하는 과정에서 실측(2026-09-16, 실제 API 응답): `usage.cost` 는 문서 예시(평평한 숫자)와 달리 `{input_tokens_cost, output_tokens_cost, request_cost, total_cost}` 객체였다 — 그대로 가정하고 배포했다가 지역 이야기 생성(LOCAL_SYNC) 잡이 재시도 3회 후 DEAD 로 떨어지는 걸 라이브에서 확인하고 고쳤다. 어떤 모양이 와도 예외를 던지지 않게 방어했다. - services/llm/perplexity.py: Usage·read_usage() 추가(usage.cost.total_cost 를 그대로 읽는다 — 토큰 단가표로 역산하지 않는다. 검색 컨텍스트 요금까지 포함된 진짜 값이라서다) - external/perplexity.py·place_research.py·story_service.py·itinerary_llm_service.py· external/restaurant_discovery.py: 각 호출부에 tokens/비용 로그 추가 실측(스테이,머뭄 1건 발행, 지역 콘텐츠는 캐시): Perplexity $0.050(일정 생성이 절반 이상), OpenAI $0.019(비전 $0.015 + 소개문·FAQ $0.003 + 가사 $0.0006). 검증: 신규/영향받은 테스트 전부 통과(services/llm 신규 3파일, gemini_extract 최초 HTTP 계층 테스트, perplexity 비용 계측 등). 실 OpenAI/Perplexity API로 사업장 수집→비전→ 소개문·FAQ→발행까지 라이브로 왕복 확인. ## 3. site/EssentialInfoSection.tsx 미확인 항목 개수 안내 문구 제거(별도 작업, 스테이징된 상태 그대로 포함). Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 2 +- solution/backend/config/config_models.py | 4 + solution/backend/services/copy_steps.py | 10 +- solution/backend/services/external/gemini.py | 70 +++++----- .../services/external/gemini_extract.py | 55 +++----- .../backend/services/external/gemini_text.py | 117 +++++++--------- .../backend/services/external/perplexity.py | 6 +- .../services/external/restaurant_discovery.py | 10 +- .../backend/services/itinerary_llm_service.py | 12 +- solution/backend/services/llm/errors.py | 16 +++ solution/backend/services/llm/gemini.py | 64 +++++---- solution/backend/services/llm/openai.py | 127 ++++++++++++++++++ solution/backend/services/llm/perplexity.py | 34 +++++ solution/backend/services/llm/provider.py | 10 ++ solution/backend/services/llm/types.py | 28 ++++ solution/backend/services/place_research.py | 6 +- solution/backend/services/story_service.py | 6 +- solution/backend/services/vision_service.py | 10 +- .../src/sections/EssentialInfoSection.tsx | 15 +-- 19 files changed, 410 insertions(+), 192 deletions(-) create mode 100644 solution/backend/services/llm/errors.py create mode 100644 solution/backend/services/llm/openai.py create mode 100644 solution/backend/services/llm/provider.py create mode 100644 solution/backend/services/llm/types.py diff --git a/.env.example b/.env.example index 98c9092..6a2f0a9 100644 --- a/.env.example +++ b/.env.example @@ -33,7 +33,7 @@ NAVER_CLIENT_ID= NAVER_CLIENT_SECRET= # 미발급. 없으면 네이버 지역검색을 쓴다 KAKAO_REST_API_KEY= -GEMINI_API_KEY= +OPENAI_API_KEY= # 디코딩된 키(인코딩 키는 이중 인코딩된다) TOUR_API_KEY= # 발행할 때 이 숙소의 노래를 한 곡 만든다(가사 Gemini → 작곡 Suno). diff --git a/solution/backend/config/config_models.py b/solution/backend/config/config_models.py index 70e692c..1fe8480 100644 --- a/solution/backend/config/config_models.py +++ b/solution/backend/config/config_models.py @@ -128,6 +128,10 @@ class ExternalApiConfig(BaseSettings): # 3.7 기본: 라벨이 틀리면 사람 확인 큐 비용이 모델 값 차이(1건 $0.045 vs $0.018)보다 크다. gemini_vision_model: str = Field("gemini-3.7-flash", validation_alias="GEMINI_VISION_MODEL") gemini_text_model: str = Field("gemini-3.7-flash", validation_alias="GEMINI_TEXT_MODEL") + llm_provider: str = Field("openai", validation_alias="LLM_PROVIDER") + openai_api_key: str = Field("", validation_alias="OPENAI_API_KEY") + openai_text_model: str = Field("gpt-5.6-luna", validation_alias="OPENAI_TEXT_MODEL") + openai_vision_model: str = Field("gpt-5.6-luna", validation_alias="OPENAI_VISION_MODEL") # 이 값 미만이면 자동 반영하지 않고 사람 확인 큐(PENDING_REVIEW)에 남긴다. vision_confidence_threshold: float = Field(0.7, validation_alias="VISION_CONFIDENCE_THRESHOLD") tour_api_key: str = Field("", validation_alias="TOUR_API_KEY") diff --git a/solution/backend/services/copy_steps.py b/solution/backend/services/copy_steps.py index 993ec41..bbafb86 100644 --- a/solution/backend/services/copy_steps.py +++ b/solution/backend/services/copy_steps.py @@ -37,6 +37,7 @@ from crud.place_crud import PlaceCRUD from router.v1.fact.protocol import Req_UpsertFact from services import faq_fill, place_research from services.external import gemini_text +from services.llm import provider from services.fact_service import FactService from common.job_errors import PermanentJobError @@ -181,6 +182,11 @@ async def prepare_copy(place_id: str, owner_user_id: str) -> CopyInputs: async def generate_copy(inputs: CopyInputs) -> gemini_text.GeneratedCopy: + active_provider = provider.active() + model = ( + external_api_config.openai_text_model if active_provider.__name__.endswith("openai") + else external_api_config.gemini_text_model + ) try: return await gemini_text.generate_copy( inputs.place.name, @@ -190,7 +196,7 @@ async def generate_copy(inputs: CopyInputs) -> gemini_text.GeneratedCopy: records=inputs.records or None, suggested_questions=faq_fill.suggested_questions(inputs.catalog, inputs.known_fact_keys) if inputs.catalog else None, max_faqs=faq_fill.FAQ_TARGET, - model=external_api_config.gemini_text_model, + model=model, ) except gemini_text.GeminiNotConfigured as ex: raise CopyAborted(str(ex)) from ex @@ -243,7 +249,7 @@ async def save_copy(inputs: CopyInputs, copy: gemini_text.GeneratedCopy | None) actor, place_id, Req_UpsertFact( key=key, value=text_value.strip(), - source_type=SourceType.LLM, source_url=f"gemini:{external_api_config.gemini_text_model}", + source_type=SourceType.LLM, source_url=f"llm:{copy.source or external_api_config.gemini_text_model}", ), ) if res.result.success: diff --git a/solution/backend/services/external/gemini.py b/solution/backend/services/external/gemini.py index 12f718b..a40935a 100644 --- a/solution/backend/services/external/gemini.py +++ b/solution/backend/services/external/gemini.py @@ -3,15 +3,13 @@ 이 파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다: 무엇을 묻는가 services/prompts/vision.py 프롬프트·응답 스키마 - 어떻게 부르는가 services/llm/gemini.py HTTP·재시도·토큰·비용 + 어떻게 부르는가 services/llm/provider.py 공급자 선택(gemini/openai) · HTTP·재시도·토큰·비용 무엇을 돌려주는가 여기 배치 나누기 → 호출 → ref 매칭 → 조립 결과는 순서가 아니라 `ref` 로 매칭하고, 신뢰도가 낮으면 사람 확인 대상으로 남긴다. (문장 생성과 달리 여기엔 grounding 겹이 없다 — 사진 설명은 대조할 fact 가 없고, 대신 신뢰도 임계값과 사람 확인 큐가 그 몫을 한다.) """ -import base64 -import json from dataclasses import dataclass, field from typing import Optional @@ -19,20 +17,19 @@ import httpx from common.enums import PlaceCategory from common.logger import LOG -from services.llm.gemini import ( - DEFAULT_MODEL, - GeminiError, - GeminiInvalidOutput, - GeminiNotConfigured, - Usage, - call, - extract_text, - is_configured, - price, - read_usage, -) +from services.llm import provider +from services.llm.errors import LlmError as GeminiError +from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput # noqa: F401 (하위 호환 재노출) +from services.llm.errors import LlmNotConfigured as GeminiNotConfigured +from services.llm.types import ImagePart, Usage from services.prompts.vision import RESPONSE_SCHEMA, build_prompt + +def is_configured() -> bool: + """호출측(vision_service.py 등)은 이 겹만 안다 — 어느 공급자가 활성인지는 몰라도 된다.""" + return provider.active().is_configured() + + # URL 확장자 대신 파일 시그니처로 MIME을 판별한다. _MAGIC = ( (b"\x89PNG\r\n\x1a\n", "image/png"), @@ -113,7 +110,7 @@ async def _run_batch( ★ 배치가 통째로 실패해도 예외를 밖으로 던지지 않는다 — 호출측이 나머지 배치를 계속 돌려야 한다.""" out: dict[str, VisionResult] = {} ref_map: dict[str, ImageInput] = {} - parts: list[dict] = [{"text": ""}] # 자리를 잡아두고 프롬프트는 아래에서 채운다 + images_payload: list[ImagePart] = [] for i, image in enumerate(batch): ref = f"img-{i}" @@ -127,33 +124,27 @@ async def _run_batch( ) continue ref_map[ref] = image - parts.append({"text": f"[{ref}]" + (f" (힌트: {image.unit_name_hint})" if image.unit_name_hint else "")}) - parts.append({ - "inline_data": {"mime_type": image.mime_type or _sniff_mime(data), "data": base64.b64encode(data).decode()} - }) + label = f"[{ref}]" + (f" (힌트: {image.unit_name_hint})" if image.unit_name_hint else "") + images_payload.append(ImagePart(mime_type=image.mime_type or _sniff_mime(data), data=data, label=label)) if not ref_map: return out - parts[0] = {"text": build_prompt(category, unit_names, sorted(ref_map))} - body = { - "contents": [{"role": "user", "parts": parts}], - "generationConfig": { - "responseMimeType": "application/json", - "responseSchema": RESPONSE_SCHEMA, - "temperature": 0, - }, - } + prompt = build_prompt(category, unit_names, sorted(ref_map)) + llm_provider = provider.active() usage.batches += 1 try: - payload = await call(client, model, body, max_retries) - parsed = json.loads(extract_text(payload)) + llm_result = await llm_provider.generate( + client, model, prompt=prompt, images=images_payload, + response_schema=RESPONSE_SCHEMA, temperature=0, max_retries=max_retries, + ) + parsed = llm_result.json except GeminiNotConfigured: raise # 키 문제는 전체를 중단시킨다 — 나머지 배치도 어차피 실패한다 except Exception as ex: usage.failed_batches += 1 - LOG.w(f"[gemini] 배치 실패(계속) {len(ref_map)}장: {type(ex).__name__}: {ex}") + LOG.w(f"[vision] 배치 실패(계속) {len(ref_map)}장: {type(ex).__name__}: {ex}") for image in ref_map.values(): out[image.origin_url] = VisionResult( origin_url=image.origin_url, ok=False, needs_review=True, @@ -161,7 +152,7 @@ async def _run_batch( ) return out - batch_usage = read_usage(payload) + batch_usage = llm_result.usage usage.input_tokens += batch_usage.input_tokens usage.output_tokens += batch_usage.output_tokens @@ -205,7 +196,7 @@ async def analyze_images( *, category: Optional[PlaceCategory] = None, unit_names: Optional[list[str]] = None, - model: str = DEFAULT_MODEL, + model: Optional[str] = None, batch_size: int = 10, confidence_threshold: float = 0.7, max_retries: int = 2, @@ -217,8 +208,10 @@ async def analyze_images( 호출측이 길이나 순서로 매칭하다 어긋나면 엉뚱한 사진에 alt 가 붙는다. ★ needs_review=True 인 항목은 자동 반영하지 말고 사람 확인 큐(MediaStatus.PENDING_REVIEW)로 보낸다. """ - if not is_configured(): - raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다") + llm_provider = provider.active() + if not llm_provider.is_configured(): + raise GeminiNotConfigured("API 키가 설정되지 않았다") + model = model or llm_provider.DEFAULT_MODEL if not images: return [] @@ -249,8 +242,9 @@ async def analyze_images( ok = sum(1 for r in results if r.ok) review = sum(1 for r in results if r.needs_review) LOG.i( - f"[gemini] 사진분석 {len(results)}장 (성공 {ok} · 확인필요 {review}) · " + f"[vision] 사진분석 {len(results)}장 (성공 {ok} · 확인필요 {review}) · " f"배치 {usage.batches}(실패 {usage.failed_batches}) · model={model} · " - f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, Usage(usage.input_tokens, usage.output_tokens))}" + f"tokens in={usage.input_tokens} out={usage.output_tokens} · " + f"약 ${llm_provider.price(model, Usage(usage.input_tokens, usage.output_tokens))}" ) return results diff --git a/solution/backend/services/external/gemini_extract.py b/solution/backend/services/external/gemini_extract.py index 9c2a267..65fd87b 100644 --- a/solution/backend/services/external/gemini_extract.py +++ b/solution/backend/services/external/gemini_extract.py @@ -3,7 +3,7 @@ 이 파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다: 무엇을 묻는가 services/prompts/extract.py 프롬프트·응답 스키마 - 어떻게 부르는가 services/llm/gemini.py HTTP·재시도·토큰·비용 + 어떻게 부르는가 services/llm/provider.py 공급자 선택(gemini/openai) · HTTP·재시도·토큰·비용 답을 믿을 것인가 services/grounding/extract.py evidence 원문 대조 무엇을 돌려주는가 여기 호출 → 검증 → CollectedFact 조립 @@ -15,7 +15,6 @@ 통과한 fact 도 UNVERIFIED 로 들어간다. 사장님이 확인해야 사이트에 나간다 — 그 게이트는 fact 계층이 담당한다. 여기서는 '원문에 있었다' 까지만 보장한다. """ -import json from dataclasses import dataclass, field from typing import Optional @@ -26,16 +25,9 @@ from common.enums import PlaceCategory from common.logger import LOG from services.collector.base import CollectedFact from services.grounding.extract import verify -from services.llm.gemini import ( - DEFAULT_MODEL, - GeminiInvalidOutput, - GeminiNotConfigured, - call, - extract_text, - is_configured, - price, - read_usage, -) +from services.llm import provider +from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput +from services.llm.errors import LlmNotConfigured as GeminiNotConfigured from services.prompts.extract import RESPONSE_SCHEMA, build_prompt # 이보다 짧은 원문은 호출하지 않는다. 메뉴판 한 줄도 안 되는 분량에서 나올 fact 는 없고, @@ -65,7 +57,7 @@ async def extract_facts( source_text: str, *, source_url: str, - model: str = DEFAULT_MODEL, + model: Optional[str] = None, max_retries: int = 2, client: Optional[httpx.AsyncClient] = None, ) -> ExtractResult: @@ -75,38 +67,31 @@ async def extract_facts( 여기서 구조적으로 찍어 둔다(사장님 붙여넣기면 'owner:paste' 같은 식별자라도 넣는다). ★ 원문이 짧으면 **API 를 호출하지 않는다** — 근거가 없는데 부르면 그게 곧 환각 유발이다. """ - if not is_configured(): - raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다") + llm = provider.active() + if not llm.is_configured(): + raise GeminiNotConfigured("API 키가 설정되지 않았다") if not (source_url or "").strip(): raise ValueError("source_url 이 비었다 — 출처 없는 추출은 하지 않는다") + model = model or llm.DEFAULT_MODEL text = (source_text or "").strip() if len(text) < MIN_SOURCE_CHARS: - LOG.i(f"[gemini-extract] '{place_name}' 원문 {len(text)}자 — 짧아서 호출하지 않는다") + LOG.i(f"[extract] '{place_name}' 원문 {len(text)}자 — 짧아서 호출하지 않는다") return ExtractResult(rejected=[("(전체)", f"원문이 {len(text)}자로 너무 짧다 — 호출하지 않았다")]) - body = { - "contents": [{"role": "user", "parts": [{"text": build_prompt(place_name, category, text)}]}], - "generationConfig": { - "responseMimeType": "application/json", - "responseSchema": RESPONSE_SCHEMA, - # ★ 0.0 — 옮겨 적는 작업이다. 창의성이 개입할 자리가 없다. - "temperature": 0.0, - }, - } - owns_client = client is None client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0)) try: - payload = await call(client, model, body, max_retries) - parsed = json.loads(extract_text(payload)) - except json.JSONDecodeError as ex: - raise GeminiInvalidOutput(f"구조화 출력 파싱 실패: {ex}") from ex + llm_result = await llm.generate( + client, model, prompt=build_prompt(place_name, category, text), + # ★ 0.0 — 옮겨 적는 작업이다. 창의성이 개입할 자리가 없다. + response_schema=RESPONSE_SCHEMA, temperature=0.0, max_retries=max_retries, + ) finally: if owns_client: await client.aclose() - rows = parsed.get("facts") + rows = llm_result.json.get("facts") if llm_result.json else None if not isinstance(rows, list): raise GeminiInvalidOutput(f"facts 가 배열이 아니다: {type(rows).__name__}") @@ -124,14 +109,14 @@ async def extract_facts( for row in passed ] - usage = read_usage(payload) + usage = llm_result.usage LOG.i( - f"[gemini-extract] '{place_name}' 추출 {len(rows)}건 → 통과 {len(facts)}건 · " + f"[extract] '{place_name}' 추출 {len(rows)}건 → 통과 {len(facts)}건 · " f"반려 {len(rejected)}건 · model={model} · " - f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, usage)}" + f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${llm.price(model, usage)}" ) if rejected: for label, why in rejected[:10]: - LOG.w(f"[gemini-extract] 반려 {label} — {why}") + LOG.w(f"[extract] 반려 {label} — {why}") return ExtractResult(facts=facts, rejected=rejected) diff --git a/solution/backend/services/external/gemini_text.py b/solution/backend/services/external/gemini_text.py index efd6e1e..682c7f3 100644 --- a/solution/backend/services/external/gemini_text.py +++ b/solution/backend/services/external/gemini_text.py @@ -3,7 +3,7 @@ 이 파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다: 무엇을 묻는가 services/prompts/copy.py 프롬프트·응답 스키마 - 어떻게 부르는가 services/llm/gemini.py HTTP·재시도·토큰·비용 + 어떻게 부르는가 services/llm/provider.py 공급자 선택(gemini/openai) · HTTP·재시도·토큰·비용 답을 믿을 것인가 services/grounding/copy.py ground_check · faq_polarity_ok 무엇을 돌려주는가 여기 근거 모으기 → 호출 → 검증 → 조립 @@ -11,7 +11,6 @@ 어디를 봐야 할지가 파일 안에서 갈리지 않았다. """ import hashlib -import json from dataclasses import dataclass, field from typing import Optional @@ -20,21 +19,18 @@ import httpx from common.enums import PlaceCategory from common.logger import LOG from services.grounding.copy import FactInput, faq_polarity_ok, ground_check -from services.llm.gemini import ( - DEFAULT_MODEL as DEFAULT_TEXT_MODEL, - GeminiError, - GeminiInvalidOutput, - GeminiNotConfigured, - Usage, - call, - extract_text, - is_configured, - price, - read_usage, -) +from services.llm import provider +from services.llm.errors import LlmError +from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput +from services.llm.errors import LlmNotConfigured as GeminiNotConfigured from services.prompts.copy import RESPONSE_SCHEMA, build_prompt +def is_configured() -> bool: + """호출측(copy_service.py, place_service.py 등)은 이 겹만 안다 — 어느 공급자가 활성인지는 몰라도 된다.""" + return provider.active().is_configured() + + @dataclass class GeneratedFaq: question: str @@ -54,6 +50,7 @@ class GeneratedCopy: meta_description: Optional[str] = None faqs: list[GeneratedFaq] = field(default_factory=list) rejected: list[tuple[str, str]] = field(default_factory=list) + source: str = "" # ★ "openai:gpt-5.6-luna" 형식 — copy_steps.py 가 fact 출처 표기에 쓴다 def _unit_facts(unit_summaries: Optional[list[dict]]) -> list[FactInput]: @@ -100,7 +97,7 @@ async def generate_copy( records: Optional[list[str]] = None, suggested_questions: Optional[list[str]] = None, max_faqs: int = 8, - model: str = DEFAULT_TEXT_MODEL, + model: Optional[str] = None, max_retries: int = 2, client: Optional[httpx.AsyncClient] = None, ) -> GeneratedCopy: @@ -111,13 +108,15 @@ async def generate_copy( ★ 생성 결과는 전부 ground_check 를 통과한 것만 담긴다. 통과 못 한 항목은 rejected 로 간다. ★ 생성 대상 필드는 업종 스키마의 allow_llm=True 인 것뿐이다(호출측이 필터링해서 넘긴다). """ - if not is_configured(): - raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다") + llm = provider.active() + if not llm.is_configured(): + raise GeminiNotConfigured(f"{llm.__name__.rsplit('.', 1)[-1].upper()}_API_KEY 가 설정되지 않았다") + model = model or llm.DEFAULT_MODEL # ★ 사업장 fact 가 없어도 객실·메뉴 근거가 있으면 쓴다. 요금표만 있는 모텔이 그 경우다 — # "대실 20,000원" 은 근거 있는 사실이고, 손님이 가장 먼저 묻는 것이기도 하다. unit_grounding = _unit_facts(unit_summaries) if not facts and not unit_grounding: - LOG.i(f"[gemini-text] '{place_name}' 근거 fact 0건 — 생성하지 않는다(호출 없음)") + LOG.i(f"[llm-text] '{place_name}' 근거 fact 0건 — 생성하지 않는다(호출 없음)") return GeneratedCopy(rejected=[("(전체)", "근거 fact 가 없다 — 생성하지 않았다")]) # 검증에 쓸 근거 = 넘겨받은 fact + 객실 요약 + 상호명(상호에 숫자가 있어도 근거로 본다) @@ -125,31 +124,22 @@ async def generate_copy( grounding.append(FactInput(key="place_name", label="상호명", value=place_name)) allowed_keys = {f.key for f in facts} | {f.key for f in grounding} - body = { - "contents": [{"role": "user", "parts": [{ - "text": build_prompt(place_name, category, facts, max_faqs, unit_grounding, records, suggested_questions) - }]}], - "generationConfig": { - "responseMimeType": "application/json", - "responseSchema": RESPONSE_SCHEMA, - "temperature": 0.2, - }, - } + prompt = build_prompt(place_name, category, facts, max_faqs, unit_grounding, records, suggested_questions) owns_client = client is None client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0)) try: - payload = await call(client, model, body, max_retries) - parsed = json.loads(extract_text(payload)) - except json.JSONDecodeError as ex: - raise GeminiInvalidOutput(f"구조화 출력 파싱 실패: {ex}") from ex + llm_result = await llm.generate( + client, model, prompt=prompt, response_schema=RESPONSE_SCHEMA, temperature=0.2, max_retries=max_retries, + ) finally: if owns_client: await client.aclose() - usage = read_usage(payload) + parsed = llm_result.json + usage = llm_result.usage - result = GeneratedCopy() + result = GeneratedCopy(source=f"{llm.__name__.rsplit('.', 1)[-1]}:{model}") # ── 소개문 ── intro = (parsed.get("intro") or "").strip() @@ -190,10 +180,10 @@ async def generate_copy( result.faqs.append(GeneratedFaq(question=question, answer=answer, fact_keys=keys)) LOG.i( - f"[gemini-text] '{place_name}' 생성 — 소개문 {'O' if result.intro else 'X'} · " + f"[llm-text] '{place_name}' 생성 — 소개문 {'O' if result.intro else 'X'} · " f"메타 {'O' if result.meta_description else 'X'} · FAQ {len(result.faqs)}건 · " f"반려 {len(result.rejected)}건 · model={model} · " - f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, usage)}" + f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${llm.price(model, usage)}" ) return result @@ -215,7 +205,7 @@ _SUMMARY_PROMPT = ( async def summarize_text( text: str, *, - model: str = DEFAULT_TEXT_MODEL, + model: Optional[str] = None, max_retries: int = 2, client: Optional[httpx.AsyncClient] = None, ) -> Optional[str]: @@ -227,8 +217,10 @@ async def summarize_text( stripped = text.strip() if not stripped: return None - if not is_configured(): + llm = provider.active() + if not llm.is_configured(): return None + model = model or llm.DEFAULT_MODEL # 길이 기준을 바꾼 뒤 이전 길이의 요약을 재사용하지 않도록 프롬프트도 키에 넣는다. cache_key = hashlib.sha256((_SUMMARY_PROMPT + stripped).encode("utf-8")).hexdigest() @@ -236,20 +228,13 @@ async def summarize_text( if cached is not None: return cached - body = { - "contents": [{"role": "user", "parts": [{ - "text": _SUMMARY_PROMPT + stripped, - }]}], - "generationConfig": {"temperature": 0.2}, - } - owns_client = client is None client = client or httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0)) try: - payload = await call(client, model, body, max_retries) - summary = extract_text(payload).strip() - except GeminiError as ex: - LOG.w(f"[gemini-text] 요약 실패: {ex}") + result = await llm.generate(client, model, prompt=_SUMMARY_PROMPT + stripped, temperature=0.2, max_retries=max_retries) + summary = result.text.strip() + except LlmError as ex: + LOG.w(f"[llm-text] 요약 실패: {ex}") return None finally: if owns_client: @@ -279,7 +264,7 @@ async def generate_song( region: str, grounding: list[str], intro: str = "", - model: str = DEFAULT_TEXT_MODEL, + model: Optional[str] = None, max_retries: int = 2, client: Optional[httpx.AsyncClient] = None, ) -> GeneratedSong: @@ -291,47 +276,39 @@ async def generate_song( ★ 재료가 하나도 없으면 부르지 않는다 — 소개문과 같은 규칙이다. 상호와 지역만으로 쓴 노래는 어느 숙소에 붙여도 말이 되는 노래이고, 그건 이 기능이 하려던 일이 아니다. """ - if not is_configured(): - raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다") + llm = provider.active() + if not llm.is_configured(): + raise GeminiNotConfigured("API 키가 설정되지 않았다") if not grounding and not (intro or "").strip(): raise GeminiInvalidOutput("가사를 쓸 재료가 없다 — 확인된 fact 도 소개문도 없다") + model = model or llm.DEFAULT_MODEL from common.category_schema import get_schema from services.prompts.song import RESPONSE_SCHEMA as SONG_SCHEMA, build_prompt as build_song_prompt - body = { - "contents": [{"role": "user", "parts": [{ - "text": build_song_prompt(place_name, get_schema(category).label, region, grounding, intro) - }]}], - "generationConfig": { - "responseMimeType": "application/json", - "responseSchema": SONG_SCHEMA, - # 소개문(0.2)보다 높다 — 노래는 정확해야 하는 글이 아니라 흥얼거릴 글이다. - "temperature": 0.9, - }, - } + prompt = build_song_prompt(place_name, get_schema(category).label, region, grounding, intro) owns_client = client is None client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0)) try: - payload = await call(client, model, body, max_retries) - parsed = json.loads(extract_text(payload)) - except json.JSONDecodeError as ex: - raise GeminiInvalidOutput(f"가사 파싱 실패: {ex}") from ex + llm_result = await llm.generate( + client, model, prompt=prompt, response_schema=SONG_SCHEMA, temperature=0.9, max_retries=max_retries, + ) finally: if owns_client: await client.aclose() + parsed = llm_result.json or {} title = (parsed.get("title") or "").strip() lyrics = (parsed.get("lyrics") or "").strip() style = (parsed.get("style") or "").strip() if not lyrics: raise GeminiInvalidOutput("가사가 비어 있다") - usage = read_usage(payload) + usage = llm_result.usage LOG.i( - f"[gemini-text] '{place_name}' 가사 — '{title}' ({style}) · {len(lyrics)}자 · " - f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, usage)}" + f"[llm-text] '{place_name}' 가사 — '{title}' ({style}) · {len(lyrics)}자 · " + f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${llm.price(model, usage)}" ) # 제목이 비면 상호를 쓴다 — 빈 제목은 플레이어에서 빈 줄로 보인다. return GeneratedSong(title=title or place_name, lyrics=lyrics, style=style or "acoustic ballad") diff --git a/solution/backend/services/external/perplexity.py b/solution/backend/services/external/perplexity.py index c4507e2..80987dc 100644 --- a/solution/backend/services/external/perplexity.py +++ b/solution/backend/services/external/perplexity.py @@ -30,6 +30,7 @@ from services.llm.perplexity import ( PerplexityNotConfigured, call, is_configured, + read_usage, ) from services.prompts.channel_discovery import RESPONSE_SCHEMA, SYSTEM_PROMPT, build_prompt @@ -126,12 +127,13 @@ async def discover_channels( ) # 내부 검색 횟수는 품질·지연 관측값이다. Sonar 과금은 토큰 + 요청 컨텍스트 요금이다. - usage = payload.get("usage") or {} + usage = read_usage(payload) reasons = result.reason_counts() reason_text = " ".join(f"{k}{v}" for k, v in sorted(reasons.items())) or "없음" LOG.i( f"[perplexity] '{name}' 검색={searches}회 발견={len(found)} 통과={len(links)} " - f"탈락={len(filtered_out)}({reason_text}) tokens={usage.get('total_tokens', '?')}" + f"탈락={len(filtered_out)}({reason_text}) " + f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${usage.cost}" ) if searches > SEARCH_COUNT_WARN_THRESHOLD: LOG.w( diff --git a/solution/backend/services/external/restaurant_discovery.py b/solution/backend/services/external/restaurant_discovery.py index 30736dc..a3cddcb 100644 --- a/solution/backend/services/external/restaurant_discovery.py +++ b/solution/backend/services/external/restaurant_discovery.py @@ -8,7 +8,7 @@ import json from common.logger import LOG -from services.llm.perplexity import DEFAULT_MAX_TOKENS, DEFAULT_MODEL, PerplexityError, call +from services.llm.perplexity import DEFAULT_MAX_TOKENS, DEFAULT_MODEL, PerplexityError, call, read_usage from services.prompts.restaurant_search import RESPONSE_SCHEMA, SYSTEM_PROMPT, build_prompt MAX_RESULTS = 10 @@ -54,4 +54,10 @@ async def search_region_restaurants( except PerplexityError as ex: LOG.w(f"[restaurant_discovery] '{region_label}' 검색 실패: {ex}") return [] - return _parse_names(payload) + names = _parse_names(payload) + usage = read_usage(payload) + LOG.i( + f"[restaurant_discovery] '{region_label}' {len(names)}곳 · " + f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${usage.cost}" + ) + return names diff --git a/solution/backend/services/itinerary_llm_service.py b/solution/backend/services/itinerary_llm_service.py index 1c7f328..728701f 100644 --- a/solution/backend/services/itinerary_llm_service.py +++ b/solution/backend/services/itinerary_llm_service.py @@ -126,6 +126,8 @@ async def _generate_one( courses: list[dict] = [] seen: set[frozenset[str]] = set() notes: list[str] = [] + total_in = total_out = 0 + total_cost = 0.0 for attempt in range(1, MAX_ATTEMPTS + 1): try: payload = await perplexity.call(body, client=client) @@ -136,6 +138,11 @@ async def _generate_one( notes.append(f"{attempt}차 호출 실패: {ex}") break # 같은 오류가 반복될 걸 재시도로 밀어붙이지 않는다 — 지금까지 모은 것만 쓴다 + usage = perplexity.read_usage(payload) + total_in += usage.input_tokens + total_out += usage.output_tokens + total_cost += usage.cost + new_courses, dropped = grounding.parse_courses( payload, duration, place_name, place_lat, place_lng, already_seen=seen) notes += dropped @@ -151,7 +158,10 @@ async def _generate_one( if len(courses) < TARGET_COURSES: notes.append(f"{MAX_ATTEMPTS}차 시도 후에도 {len(courses)}/{TARGET_COURSES}개만 채웠다") - LOG.i(f"[itinerary] {place_name} {duration}: {len(courses)}개 채택, {len(notes)}건 버림/안내") + LOG.i( + f"[itinerary] {place_name} {duration}: {len(courses)}개 채택, {len(notes)}건 버림/안내 · " + f"tokens in={total_in} out={total_out} · 약 ${round(total_cost, 6)}" + ) return courses, notes diff --git a/solution/backend/services/llm/errors.py b/solution/backend/services/llm/errors.py new file mode 100644 index 0000000..1e1224e --- /dev/null +++ b/solution/backend/services/llm/errors.py @@ -0,0 +1,16 @@ +"""공급자 무관 LLM 예외. Gemini·OpenAI 구현이 둘 다 이 클래스를 던진다. + +★ 이름을 'Llm*'으로 새로 지었지만 services/llm/gemini.py 가 GeminiError = LlmError 식으로 + 같은 클래스를 재노출한다 — 기존 6개 파일의 `except GeminiNotConfigured` 는 한 글자도 안 바뀐다.""" + + +class LlmError(RuntimeError): + """LLM 호출 실패.""" + + +class LlmNotConfigured(LlmError): + """API 키 미설정 또는 인증 실패(401/403).""" + + +class LlmInvalidOutput(LlmError): + """응답이 기대한 모양이 아니다.""" diff --git a/solution/backend/services/llm/gemini.py b/solution/backend/services/llm/gemini.py index 5c995bb..0846c11 100644 --- a/solution/backend/services/llm/gemini.py +++ b/solution/backend/services/llm/gemini.py @@ -8,11 +8,17 @@ 여기가 책임지지 않는 것: 무엇을 물을지(services/prompts/), 답을 믿을지(services/grounding/). """ import asyncio -from dataclasses import dataclass +import base64 +import json import httpx from config.server_configs import external_api_config +from services.llm.errors import LlmError as GeminiError +from services.llm.errors import LlmInvalidOutput +from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput # noqa: F401 (하위 호환 재노출) +from services.llm.errors import LlmNotConfigured as GeminiNotConfigured +from services.llm.types import ImagePart, LlmResult, Usage _BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models" @@ -26,28 +32,6 @@ _PRICE_PER_1M_OUTPUT = {"gemini-3.7-flash": 3.75, "gemini-3.6-flash": 3.75, "gem _RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504} -class GeminiError(RuntimeError): - """Gemini 호출 실패 — 호출측은 ErrorType.GENERATOR_CALL_FAILED 로 매핑한다.""" - - -class GeminiNotConfigured(GeminiError): - """GEMINI_API_KEY 미설정 또는 인증 실패. - - ★ 서버 부팅은 막지 않는다 — 이 어댑터만 비활성이고 나머지 파이프라인은 돈다.""" - - -class GeminiInvalidOutput(GeminiError): - """응답이 기대한 모양이 아니다 — ErrorType.GENERATOR_INVALID_OUTPUT.""" - - -@dataclass -class Usage: - """호출 1회(또는 여러 회 합산)의 토큰 사용량. 비용 로그의 근거다.""" - - input_tokens: int = 0 - output_tokens: int = 0 - - def is_configured() -> bool: """키가 있는지 — 어댑터 등록/스킵 판단용. 예외를 던지지 않는다.""" return bool(external_api_config.gemini_api_key) @@ -121,3 +105,37 @@ def price(model: str, usage: Usage) -> float: inp = _PRICE_PER_1M_INPUT.get(model, 0.0) * usage.input_tokens / 1_000_000 out = _PRICE_PER_1M_OUTPUT.get(model, 0.0) * usage.output_tokens / 1_000_000 return round(inp + out, 4) + + +async def generate( + client: httpx.AsyncClient, + model: str, + *, + prompt: str, + images: list[ImagePart] | None = None, + response_schema: dict | None = None, + temperature: float = 0.2, + max_retries: int = 2, +) -> LlmResult: + """공급자 무관 인터페이스. services/llm/openai.py 가 같은 시그니처로 구현한다.""" + parts: list[dict] = [{"text": prompt}] + for image in images or []: + if image.label: + parts.append({"text": image.label}) + parts.append({"inline_data": {"mime_type": image.mime_type, "data": base64.b64encode(image.data).decode()}}) + + generation_config: dict = {"temperature": temperature} + if response_schema is not None: + generation_config["responseMimeType"] = "application/json" + generation_config["responseSchema"] = response_schema + + body = {"contents": [{"role": "user", "parts": parts}], "generationConfig": generation_config} + payload = await call(client, model, body, max_retries) + text = extract_text(payload) + parsed = None + if response_schema is not None: + try: + parsed = json.loads(text) + except json.JSONDecodeError as ex: + raise LlmInvalidOutput(f"구조화 출력 파싱 실패: {ex}") from ex + return LlmResult(json=parsed, text=text, usage=read_usage(payload)) diff --git a/solution/backend/services/llm/openai.py b/solution/backend/services/llm/openai.py new file mode 100644 index 0000000..90e3d19 --- /dev/null +++ b/solution/backend/services/llm/openai.py @@ -0,0 +1,127 @@ +"""OpenAI Chat Completions 호출 — services/llm/gemini.py 와 같은 자리, 다른 공급자. + +여기가 책임지는 것: 주소·인증 헤더·재시도·구조화 출력 스키마 변환·응답 파싱·토큰 집계·비용 계산. +여기가 책임지지 않는 것: 무엇을 물을지(services/prompts/), 답을 믿을지(services/grounding/).""" +import asyncio +import base64 +import json + +import httpx + +from config.server_configs import external_api_config +from services.llm.errors import LlmError, LlmInvalidOutput, LlmNotConfigured +from services.llm.types import ImagePart, LlmResult, Usage + +_BASE_URL = "https://api.openai.com/v1/chat/completions" + +DEFAULT_MODEL = "gpt-5.6-luna" + +_PRICE_PER_1M_INPUT = {"gpt-5.6-luna": 0.20, "gpt-5.6-terra": 2.00, "gpt-5.6-sol": 5.00} +_PRICE_PER_1M_OUTPUT = {"gpt-5.6-luna": 1.20, "gpt-5.6-terra": 12.00, "gpt-5.6-sol": 30.00} + +_RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504} + + +def is_configured() -> bool: + return bool(external_api_config.openai_api_key) + + +def _to_strict_schema(schema: dict) -> dict: + """OpenAI strict 모드 요구사항(모든 object 에 additionalProperties:false, + 모든 property 가 required)을 만족하도록 재귀 변환한다. + + ★ Gemini용 RESPONSE_SCHEMA(OpenAPI 서브셋, services/prompts/*.py)를 그대로 받아 + 변환한다 — 두 벌 관리하지 않는다.""" + schema = dict(schema) + if schema.get("type") == "object" and "properties" in schema: + schema["properties"] = {k: _to_strict_schema(v) for k, v in schema["properties"].items()} + schema["required"] = list(schema["properties"].keys()) + schema["additionalProperties"] = False + elif schema.get("type") == "array" and "items" in schema: + schema["items"] = _to_strict_schema(schema["items"]) + return schema + + +def _build_messages(prompt: str, images: list[ImagePart] | None) -> list[dict]: + content: list[dict] = [{"type": "text", "text": prompt}] + for image in images or []: + if image.label: + content.append({"type": "text", "text": image.label}) + b64 = base64.b64encode(image.data).decode() + content.append({"type": "image_url", "image_url": {"url": f"data:{image.mime_type};base64,{b64}"}}) + return [{"role": "user", "content": content}] + + +async def generate( + client: httpx.AsyncClient, + model: str, + *, + prompt: str, + images: list[ImagePart] | None = None, + response_schema: dict | None = None, + temperature: float = 0.2, + max_retries: int = 2, +) -> LlmResult: + # ★ 실측(2026-09-16): gpt-5.6-luna 는 temperature 커스텀 값을 거부한다 + # ("Only the default (1) value is supported" — 400). Gemini 와 달리 이 파라미터를 + # 그냥 안 보낸다 — 공급자가 강제하는 값이라 우리가 흉내 낼 방법이 없다. + body: dict = { + "model": model, + "messages": _build_messages(prompt, images), + } + if response_schema is not None: + body["response_format"] = { + "type": "json_schema", + "json_schema": {"name": "result", "strict": True, "schema": _to_strict_schema(response_schema)}, + } + + headers = {"Content-Type": "application/json", "Authorization": f"Bearer {external_api_config.openai_api_key}"} + last = None + payload = None + for attempt in range(max_retries + 1): + try: + resp = await client.post(_BASE_URL, json=body, headers=headers) + except (httpx.TimeoutException, httpx.TransportError) as ex: + last = f"{type(ex).__name__}: {ex}" + else: + if resp.status_code == 200: + payload = resp.json() + break + if resp.status_code in (401, 403): + raise LlmNotConfigured(f"인증 실패 status={resp.status_code} — API 키를 확인하세요") + if resp.status_code not in _RETRYABLE_STATUS: + raise LlmError(f"status={resp.status_code} body={resp.text[:200]}") + last = f"status={resp.status_code}" + + if attempt < max_retries: + await asyncio.sleep(min(8.0, 1.0 * (2 ** attempt))) + + if payload is None: + raise LlmError(f"{max_retries + 1}회 시도 실패: {last}") + + choices = payload.get("choices") or [] + if not choices: + raise LlmInvalidOutput("choices 가 비었다(안전 필터 차단 가능)") + text = (choices[0].get("message") or {}).get("content") or "" + if not text.strip(): + raise LlmInvalidOutput(f"텍스트가 없다 finish_reason={choices[0].get('finish_reason')}") + + parsed = None + if response_schema is not None: + try: + parsed = json.loads(text) + except json.JSONDecodeError as ex: + raise LlmInvalidOutput(f"구조화 출력 파싱 실패: {ex}") from ex + + usage_raw = payload.get("usage") or {} + usage = Usage( + input_tokens=int(usage_raw.get("prompt_tokens") or 0), + output_tokens=int(usage_raw.get("completion_tokens") or 0), + ) + return LlmResult(json=parsed, text=text, usage=usage) + + +def price(model: str, usage: Usage) -> float: + inp = _PRICE_PER_1M_INPUT.get(model, 0.0) * usage.input_tokens / 1_000_000 + out = _PRICE_PER_1M_OUTPUT.get(model, 0.0) * usage.output_tokens / 1_000_000 + return round(inp + out, 4) diff --git a/solution/backend/services/llm/perplexity.py b/solution/backend/services/llm/perplexity.py index 1c6fc3e..90f3ed7 100644 --- a/solution/backend/services/llm/perplexity.py +++ b/solution/backend/services/llm/perplexity.py @@ -9,6 +9,7 @@ 나중에 환각을 추적할 수 있게 한다. """ import json +from dataclasses import dataclass import httpx @@ -36,6 +37,39 @@ def is_configured() -> bool: return bool((external_api_config.perplexity_api_key or "").strip()) +@dataclass +class Usage: + """호출 1회의 실제 사용량·비용. + + ★ cost 는 우리가 계산한 값이 아니라 Perplexity 가 응답에 직접 실어주는 실제 청구액(USD)이다 + (`usage.cost.total_cost`) — 검색 컨텍스트 요금(request_cost)까지 포함된 진짜 값이라, + Gemini·OpenAI 처럼 토큰 단가표로 역산하는 것보다 정확하다. + ★ 실측(2026-09-16): `usage.cost` 는 평평한 숫자가 아니라 + `{"input_tokens_cost", "output_tokens_cost", "request_cost", "total_cost"}` 객체다 — + 문서 예시(평평한 숫자)와 다르다. num_search_queries 는 비용은 아니지만 검색 남용 감시용으로 같이 둔다.""" + + input_tokens: int = 0 + output_tokens: int = 0 + num_search_queries: int = 0 + cost: float = 0.0 + + +def read_usage(payload: dict) -> Usage: + """응답의 usage 를 읽는다. 필드가 없거나 모양이 다르면 0 — 계측 실패가 본 기능을 막으면 안 된다.""" + u = payload.get("usage") or {} + cost_field = u.get("cost") + if isinstance(cost_field, dict): + cost = cost_field.get("total_cost") + else: + cost = cost_field + return Usage( + input_tokens=int(u.get("prompt_tokens") or 0), + output_tokens=int(u.get("completion_tokens") or 0), + num_search_queries=int(u.get("num_search_queries") or 0), + cost=float(cost) if isinstance(cost, (int, float)) else 0.0, + ) + + async def call(body: dict, *, client: httpx.AsyncClient | None = None) -> dict: """★ LLM 이 실제로 불리는 지점. chat/completions 1회. diff --git a/solution/backend/services/llm/provider.py b/solution/backend/services/llm/provider.py new file mode 100644 index 0000000..4dcea41 --- /dev/null +++ b/solution/backend/services/llm/provider.py @@ -0,0 +1,10 @@ +"""LLM_PROVIDER 설정으로 gemini/openai 구현 중 하나를 고른다. + +★ 모르는 값은 gemini 로 떨어진다 — 오타 하나로 사진분류·소개문·FAQ 가 전부 + 조용히 꺼지는 것보다, 기존에 검증된 공급자로 계속 도는 쪽이 안전하다.""" +from config.server_configs import external_api_config +from services.llm import gemini, openai + + +def active(): + return openai if external_api_config.llm_provider == "openai" else gemini diff --git a/solution/backend/services/llm/types.py b/solution/backend/services/llm/types.py new file mode 100644 index 0000000..b39366e --- /dev/null +++ b/solution/backend/services/llm/types.py @@ -0,0 +1,28 @@ +"""공급자 무관 값 타입. gemini.py·openai.py 가 동일하게 이 타입을 쓰고 돌려준다.""" +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class Usage: + input_tokens: int = 0 + output_tokens: int = 0 + + +@dataclass +class ImagePart: + mime_type: str + data: bytes + label: str = "" # 있으면 이 이미지 직전에 라벨 텍스트를 넣는다(사진 여러 장을 ref로 매칭할 때 쓴다) + + +@dataclass +class LlmResult: + """generate() 의 반환값. + + json: response_schema 를 줬을 때 파싱된 결과(스키마 없이 부르면 None). + text: 원문 텍스트(요약처럼 스키마 없는 호출에서 이걸 쓴다).""" + + json: Optional[dict] + text: str + usage: Usage diff --git a/solution/backend/services/place_research.py b/solution/backend/services/place_research.py index 7172850..c0c5722 100644 --- a/solution/backend/services/place_research.py +++ b/solution/backend/services/place_research.py @@ -98,7 +98,11 @@ async def research_place(place, place_id: str) -> dict: return {"error": str(ex)} items, dropped = grounding.parse_items(payload, name, prompts.MAX_ITEMS) - LOG.i(f"[research] '{name}' 조사 {len(items)}건 채택, {len(dropped)}건 버림") + usage = perplexity.read_usage(payload) + LOG.i( + f"[research] '{name}' 조사 {len(items)}건 채택, {len(dropped)}건 버림 · " + f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${usage.cost}" + ) if not items: return {"items": 0, "dropped": dropped} diff --git a/solution/backend/services/story_service.py b/solution/backend/services/story_service.py index fdcc82d..1b78578 100644 --- a/solution/backend/services/story_service.py +++ b/solution/backend/services/story_service.py @@ -76,7 +76,11 @@ async def _generate_kind(client: httpx.AsyncClient, kind: str, region_label: str items, dropped = grounding.parse_items(payload, kind, prompts.max_items(kind), region_label) items = await _attach_images(kind, items, region_label) - LOG.i(f"[story] {region_label} {kind}: {len(items)}건 채택, {len(dropped)}건 버림") + usage = perplexity.read_usage(payload) + LOG.i( + f"[story] {region_label} {kind}: {len(items)}건 채택, {len(dropped)}건 버림 · " + f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${usage.cost}" + ) return items, dropped diff --git a/solution/backend/services/vision_service.py b/solution/backend/services/vision_service.py index 1862ed3..ed3d382 100644 --- a/solution/backend/services/vision_service.py +++ b/solution/backend/services/vision_service.py @@ -18,6 +18,7 @@ from config.server_configs import external_api_config from crud.media_crud import MediaCRUD from crud.place_crud import PlaceCRUD from services.external import gemini +from services.llm import provider from common.job_errors import PermanentJobError _media_crud = MediaCRUD() @@ -36,7 +37,7 @@ async def run_vision(job: dict) -> dict: force = bool(payload.get("force")) if not gemini.is_configured(): - raise VisionAborted("GEMINI_API_KEY 미설정 — 사진 분석을 할 수 없다") + raise VisionAborted("API 키 미설정 — 사진 분석을 할 수 없다") err, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), @@ -77,12 +78,17 @@ async def run_vision(job: dict) -> dict: by_key = {(r.origin_url or r.url): r for r in rows} threshold = external_api_config.vision_confidence_threshold + active_provider = provider.active() + model = ( + external_api_config.openai_vision_model if active_provider.__name__.endswith("openai") + else external_api_config.gemini_vision_model + ) try: results = await gemini.analyze_images( images, category=PlaceCategory(place.category), unit_names=unit_names, - model=external_api_config.gemini_vision_model, + model=model, confidence_threshold=threshold, ) except gemini.GeminiNotConfigured as ex: diff --git a/solution/site/src/sections/EssentialInfoSection.tsx b/solution/site/src/sections/EssentialInfoSection.tsx index a289985..6922f73 100644 --- a/solution/site/src/sections/EssentialInfoSection.tsx +++ b/solution/site/src/sections/EssentialInfoSection.tsx @@ -11,8 +11,6 @@ import {Section} from '@site/lib/ui'; * 이용 정보 표 전체. * * 예약 전 확인은 이 섹션 한 곳에서 확인된 항목 전체를 낸다. - * ★ "확인 안 된 항목이 N개 있다"를 숨기지 않는다 — 없는 척하면 - * 손님이 다른 데서 틀린 값을 찾아 온다. 있다고 말하고 문의로 보낸다. * * ★ 이용 규정을 여기로 들여왔다 (2026-09-03) * 따로 '이용 규정' 섹션이 있었는데, 그 줄(체크인·체크아웃·취소규정·취사·반려동물·흡연· @@ -63,10 +61,6 @@ export function EssentialInfoSection() { if (['cooking_allowed', 'smoking'].includes(row.key ?? '')) return false; return RULE_LABELS.has(row.label) || structured.some((field) => field.key === row.key && field.group === 'rules'); }; - const hiddenCount = payload.facts.filter( - (fact) => fact.scope === 'place' && !rows.some((row) => row.label === fact.label), - ).length; - if (rows.length === 0 && guides.length === 0) return null; /** @@ -97,14 +91,7 @@ export function EssentialInfoSection() { title="이용안내 및 예약" lead="방문 전 확인이 필요한 운영 규정과 시설 안내입니다." footnote={ - - - {hiddenCount > 0 - ? `확인 중인 항목 ${hiddenCount}개는 표시하지 않았습니다. 필요하시면 전화로 문의해 주세요.` - : '등록 정보와 수집한 안내를 기준으로 표시합니다.'} - - {formatKoreanDate(payload.site.updatedAt)} 기준 - + {formatKoreanDate(payload.site.updatedAt)} 기준 } >