[feat] solution/backend: LLM 공급자를 OpenAI 기본값으로 전환, Perplexity 실비용 계측 추가

## 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) <noreply@anthropic.com>
This commit is contained in:
김성경 2026-09-16 17:30:11 +09:00
parent 33d27980c4
commit dbae2d4f35
19 changed files with 410 additions and 192 deletions

View File

@ -33,7 +33,7 @@ NAVER_CLIENT_ID=
NAVER_CLIENT_SECRET= NAVER_CLIENT_SECRET=
# 미발급. 없으면 네이버 지역검색을 쓴다 # 미발급. 없으면 네이버 지역검색을 쓴다
KAKAO_REST_API_KEY= KAKAO_REST_API_KEY=
GEMINI_API_KEY= OPENAI_API_KEY=
# 디코딩된 키(인코딩 키는 이중 인코딩된다) # 디코딩된 키(인코딩 키는 이중 인코딩된다)
TOUR_API_KEY= TOUR_API_KEY=
# 발행할 때 이 숙소의 노래를 한 곡 만든다(가사 Gemini → 작곡 Suno). # 발행할 때 이 숙소의 노래를 한 곡 만든다(가사 Gemini → 작곡 Suno).

View File

@ -128,6 +128,10 @@ class ExternalApiConfig(BaseSettings):
# 3.7 기본: 라벨이 틀리면 사람 확인 큐 비용이 모델 값 차이(1건 $0.045 vs $0.018)보다 크다. # 3.7 기본: 라벨이 틀리면 사람 확인 큐 비용이 모델 값 차이(1건 $0.045 vs $0.018)보다 크다.
gemini_vision_model: str = Field("gemini-3.7-flash", validation_alias="GEMINI_VISION_MODEL") 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") 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)에 남긴다. # 이 값 미만이면 자동 반영하지 않고 사람 확인 큐(PENDING_REVIEW)에 남긴다.
vision_confidence_threshold: float = Field(0.7, validation_alias="VISION_CONFIDENCE_THRESHOLD") vision_confidence_threshold: float = Field(0.7, validation_alias="VISION_CONFIDENCE_THRESHOLD")
tour_api_key: str = Field("", validation_alias="TOUR_API_KEY") tour_api_key: str = Field("", validation_alias="TOUR_API_KEY")

View File

@ -37,6 +37,7 @@ from crud.place_crud import PlaceCRUD
from router.v1.fact.protocol import Req_UpsertFact from router.v1.fact.protocol import Req_UpsertFact
from services import faq_fill, place_research from services import faq_fill, place_research
from services.external import gemini_text from services.external import gemini_text
from services.llm import provider
from services.fact_service import FactService from services.fact_service import FactService
from common.job_errors import PermanentJobError 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: 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: try:
return await gemini_text.generate_copy( return await gemini_text.generate_copy(
inputs.place.name, inputs.place.name,
@ -190,7 +196,7 @@ async def generate_copy(inputs: CopyInputs) -> gemini_text.GeneratedCopy:
records=inputs.records or None, records=inputs.records or None,
suggested_questions=faq_fill.suggested_questions(inputs.catalog, inputs.known_fact_keys) if inputs.catalog else None, suggested_questions=faq_fill.suggested_questions(inputs.catalog, inputs.known_fact_keys) if inputs.catalog else None,
max_faqs=faq_fill.FAQ_TARGET, max_faqs=faq_fill.FAQ_TARGET,
model=external_api_config.gemini_text_model, model=model,
) )
except gemini_text.GeminiNotConfigured as ex: except gemini_text.GeminiNotConfigured as ex:
raise CopyAborted(str(ex)) from 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, actor, place_id,
Req_UpsertFact( Req_UpsertFact(
key=key, value=text_value.strip(), 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: if res.result.success:

View File

@ -3,15 +3,13 @@
파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다: 파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다:
무엇을 묻는가 services/prompts/vision.py 프롬프트·응답 스키마 무엇을 묻는가 services/prompts/vision.py 프롬프트·응답 스키마
어떻게 부르는가 services/llm/gemini.py HTTP·재시도·토큰·비용 어떻게 부르는가 services/llm/provider.py 공급자 선택(gemini/openai) · HTTP·재시도·토큰·비용
무엇을 돌려주는가 여기 배치 나누기 호출 ref 매칭 조립 무엇을 돌려주는가 여기 배치 나누기 호출 ref 매칭 조립
결과는 순서가 아니라 `ref` 매칭하고, 신뢰도가 낮으면 사람 확인 대상으로 남긴다. 결과는 순서가 아니라 `ref` 매칭하고, 신뢰도가 낮으면 사람 확인 대상으로 남긴다.
(문장 생성과 달리 여기엔 grounding 겹이 없다 사진 설명은 대조할 fact 없고, (문장 생성과 달리 여기엔 grounding 겹이 없다 사진 설명은 대조할 fact 없고,
대신 신뢰도 임계값과 사람 확인 큐가 몫을 한다.) 대신 신뢰도 임계값과 사람 확인 큐가 몫을 한다.)
""" """
import base64
import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
@ -19,20 +17,19 @@ import httpx
from common.enums import PlaceCategory from common.enums import PlaceCategory
from common.logger import LOG from common.logger import LOG
from services.llm.gemini import ( from services.llm import provider
DEFAULT_MODEL, from services.llm.errors import LlmError as GeminiError
GeminiError, from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput # noqa: F401 (하위 호환 재노출)
GeminiInvalidOutput, from services.llm.errors import LlmNotConfigured as GeminiNotConfigured
GeminiNotConfigured, from services.llm.types import ImagePart, Usage
Usage,
call,
extract_text,
is_configured,
price,
read_usage,
)
from services.prompts.vision import RESPONSE_SCHEMA, build_prompt from services.prompts.vision import RESPONSE_SCHEMA, build_prompt
def is_configured() -> bool:
"""호출측(vision_service.py 등)은 이 겹만 안다 — 어느 공급자가 활성인지는 몰라도 된다."""
return provider.active().is_configured()
# URL 확장자 대신 파일 시그니처로 MIME을 판별한다. # URL 확장자 대신 파일 시그니처로 MIME을 판별한다.
_MAGIC = ( _MAGIC = (
(b"\x89PNG\r\n\x1a\n", "image/png"), (b"\x89PNG\r\n\x1a\n", "image/png"),
@ -113,7 +110,7 @@ async def _run_batch(
배치가 통째로 실패해도 예외를 밖으로 던지지 않는다 호출측이 나머지 배치를 계속 돌려야 한다.""" 배치가 통째로 실패해도 예외를 밖으로 던지지 않는다 호출측이 나머지 배치를 계속 돌려야 한다."""
out: dict[str, VisionResult] = {} out: dict[str, VisionResult] = {}
ref_map: dict[str, ImageInput] = {} ref_map: dict[str, ImageInput] = {}
parts: list[dict] = [{"text": ""}] # 자리를 잡아두고 프롬프트는 아래에서 채운다 images_payload: list[ImagePart] = []
for i, image in enumerate(batch): for i, image in enumerate(batch):
ref = f"img-{i}" ref = f"img-{i}"
@ -127,33 +124,27 @@ async def _run_batch(
) )
continue continue
ref_map[ref] = image ref_map[ref] = image
parts.append({"text": f"[{ref}]" + (f" (힌트: {image.unit_name_hint})" if image.unit_name_hint else "")}) label = f"[{ref}]" + (f" (힌트: {image.unit_name_hint})" if image.unit_name_hint else "")
parts.append({ images_payload.append(ImagePart(mime_type=image.mime_type or _sniff_mime(data), data=data, label=label))
"inline_data": {"mime_type": image.mime_type or _sniff_mime(data), "data": base64.b64encode(data).decode()}
})
if not ref_map: if not ref_map:
return out return out
parts[0] = {"text": build_prompt(category, unit_names, sorted(ref_map))} prompt = build_prompt(category, unit_names, sorted(ref_map))
body = { llm_provider = provider.active()
"contents": [{"role": "user", "parts": parts}],
"generationConfig": {
"responseMimeType": "application/json",
"responseSchema": RESPONSE_SCHEMA,
"temperature": 0,
},
}
usage.batches += 1 usage.batches += 1
try: try:
payload = await call(client, model, body, max_retries) llm_result = await llm_provider.generate(
parsed = json.loads(extract_text(payload)) client, model, prompt=prompt, images=images_payload,
response_schema=RESPONSE_SCHEMA, temperature=0, max_retries=max_retries,
)
parsed = llm_result.json
except GeminiNotConfigured: except GeminiNotConfigured:
raise # 키 문제는 전체를 중단시킨다 — 나머지 배치도 어차피 실패한다 raise # 키 문제는 전체를 중단시킨다 — 나머지 배치도 어차피 실패한다
except Exception as ex: except Exception as ex:
usage.failed_batches += 1 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(): for image in ref_map.values():
out[image.origin_url] = VisionResult( out[image.origin_url] = VisionResult(
origin_url=image.origin_url, ok=False, needs_review=True, origin_url=image.origin_url, ok=False, needs_review=True,
@ -161,7 +152,7 @@ async def _run_batch(
) )
return out return out
batch_usage = read_usage(payload) batch_usage = llm_result.usage
usage.input_tokens += batch_usage.input_tokens usage.input_tokens += batch_usage.input_tokens
usage.output_tokens += batch_usage.output_tokens usage.output_tokens += batch_usage.output_tokens
@ -205,7 +196,7 @@ async def analyze_images(
*, *,
category: Optional[PlaceCategory] = None, category: Optional[PlaceCategory] = None,
unit_names: Optional[list[str]] = None, unit_names: Optional[list[str]] = None,
model: str = DEFAULT_MODEL, model: Optional[str] = None,
batch_size: int = 10, batch_size: int = 10,
confidence_threshold: float = 0.7, confidence_threshold: float = 0.7,
max_retries: int = 2, max_retries: int = 2,
@ -217,8 +208,10 @@ async def analyze_images(
호출측이 길이나 순서로 매칭하다 어긋나면 엉뚱한 사진에 alt 붙는다. 호출측이 길이나 순서로 매칭하다 어긋나면 엉뚱한 사진에 alt 붙는다.
needs_review=True 항목은 자동 반영하지 말고 사람 확인 (MediaStatus.PENDING_REVIEW) 보낸다. needs_review=True 항목은 자동 반영하지 말고 사람 확인 (MediaStatus.PENDING_REVIEW) 보낸다.
""" """
if not is_configured(): llm_provider = provider.active()
raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다") if not llm_provider.is_configured():
raise GeminiNotConfigured("API 키가 설정되지 않았다")
model = model or llm_provider.DEFAULT_MODEL
if not images: if not images:
return [] return []
@ -249,8 +242,9 @@ async def analyze_images(
ok = sum(1 for r in results if r.ok) ok = sum(1 for r in results if r.ok)
review = sum(1 for r in results if r.needs_review) review = sum(1 for r in results if r.needs_review)
LOG.i( LOG.i(
f"[gemini] 사진분석 {len(results)}장 (성공 {ok} · 확인필요 {review}) · " f"[vision] 사진분석 {len(results)}장 (성공 {ok} · 확인필요 {review}) · "
f"배치 {usage.batches}(실패 {usage.failed_batches}) · model={model} · " 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 return results

View File

@ -3,7 +3,7 @@
파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다: 파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다:
무엇을 묻는가 services/prompts/extract.py 프롬프트·응답 스키마 무엇을 묻는가 services/prompts/extract.py 프롬프트·응답 스키마
어떻게 부르는가 services/llm/gemini.py HTTP·재시도·토큰·비용 어떻게 부르는가 services/llm/provider.py 공급자 선택(gemini/openai) · HTTP·재시도·토큰·비용
답을 믿을 것인가 services/grounding/extract.py evidence 원문 대조 답을 믿을 것인가 services/grounding/extract.py evidence 원문 대조
무엇을 돌려주는가 여기 호출 검증 CollectedFact 조립 무엇을 돌려주는가 여기 호출 검증 CollectedFact 조립
@ -15,7 +15,6 @@
통과한 fact UNVERIFIED 들어간다. 사장님이 확인해야 사이트에 나간다 통과한 fact UNVERIFIED 들어간다. 사장님이 확인해야 사이트에 나간다
게이트는 fact 계층이 담당한다. 여기서는 '원문에 있었다' 까지만 보장한다. 게이트는 fact 계층이 담당한다. 여기서는 '원문에 있었다' 까지만 보장한다.
""" """
import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
@ -26,16 +25,9 @@ from common.enums import PlaceCategory
from common.logger import LOG from common.logger import LOG
from services.collector.base import CollectedFact from services.collector.base import CollectedFact
from services.grounding.extract import verify from services.grounding.extract import verify
from services.llm.gemini import ( from services.llm import provider
DEFAULT_MODEL, from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput
GeminiInvalidOutput, from services.llm.errors import LlmNotConfigured as GeminiNotConfigured
GeminiNotConfigured,
call,
extract_text,
is_configured,
price,
read_usage,
)
from services.prompts.extract import RESPONSE_SCHEMA, build_prompt from services.prompts.extract import RESPONSE_SCHEMA, build_prompt
# 이보다 짧은 원문은 호출하지 않는다. 메뉴판 한 줄도 안 되는 분량에서 나올 fact 는 없고, # 이보다 짧은 원문은 호출하지 않는다. 메뉴판 한 줄도 안 되는 분량에서 나올 fact 는 없고,
@ -65,7 +57,7 @@ async def extract_facts(
source_text: str, source_text: str,
*, *,
source_url: str, source_url: str,
model: str = DEFAULT_MODEL, model: Optional[str] = None,
max_retries: int = 2, max_retries: int = 2,
client: Optional[httpx.AsyncClient] = None, client: Optional[httpx.AsyncClient] = None,
) -> ExtractResult: ) -> ExtractResult:
@ -75,38 +67,31 @@ async def extract_facts(
여기서 구조적으로 찍어 둔다(사장님 붙여넣기면 'owner:paste' 같은 식별자라도 넣는다). 여기서 구조적으로 찍어 둔다(사장님 붙여넣기면 'owner:paste' 같은 식별자라도 넣는다).
원문이 짧으면 **API 호출하지 않는다** 근거가 없는데 부르면 그게 환각 유발이다. 원문이 짧으면 **API 호출하지 않는다** 근거가 없는데 부르면 그게 환각 유발이다.
""" """
if not is_configured(): llm = provider.active()
raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다") if not llm.is_configured():
raise GeminiNotConfigured("API 키가 설정되지 않았다")
if not (source_url or "").strip(): if not (source_url or "").strip():
raise ValueError("source_url 이 비었다 — 출처 없는 추출은 하지 않는다") raise ValueError("source_url 이 비었다 — 출처 없는 추출은 하지 않는다")
model = model or llm.DEFAULT_MODEL
text = (source_text or "").strip() text = (source_text or "").strip()
if len(text) < MIN_SOURCE_CHARS: 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)}자로 너무 짧다 — 호출하지 않았다")]) 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 owns_client = client is None
client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0)) client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0))
try: try:
payload = await call(client, model, body, max_retries) llm_result = await llm.generate(
parsed = json.loads(extract_text(payload)) client, model, prompt=build_prompt(place_name, category, text),
except json.JSONDecodeError as ex: # ★ 0.0 — 옮겨 적는 작업이다. 창의성이 개입할 자리가 없다.
raise GeminiInvalidOutput(f"구조화 출력 파싱 실패: {ex}") from ex response_schema=RESPONSE_SCHEMA, temperature=0.0, max_retries=max_retries,
)
finally: finally:
if owns_client: if owns_client:
await client.aclose() await client.aclose()
rows = parsed.get("facts") rows = llm_result.json.get("facts") if llm_result.json else None
if not isinstance(rows, list): if not isinstance(rows, list):
raise GeminiInvalidOutput(f"facts 가 배열이 아니다: {type(rows).__name__}") raise GeminiInvalidOutput(f"facts 가 배열이 아니다: {type(rows).__name__}")
@ -124,14 +109,14 @@ async def extract_facts(
for row in passed for row in passed
] ]
usage = read_usage(payload) usage = llm_result.usage
LOG.i( 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"반려 {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: if rejected:
for label, why in rejected[:10]: 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) return ExtractResult(facts=facts, rejected=rejected)

View File

@ -3,7 +3,7 @@
파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다: 파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다:
무엇을 묻는가 services/prompts/copy.py 프롬프트·응답 스키마 무엇을 묻는가 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 답을 믿을 것인가 services/grounding/copy.py ground_check · faq_polarity_ok
무엇을 돌려주는가 여기 근거 모으기 호출 검증 조립 무엇을 돌려주는가 여기 근거 모으기 호출 검증 조립
@ -11,7 +11,6 @@
어디를 봐야 할지가 파일 안에서 갈리지 않았다. 어디를 봐야 할지가 파일 안에서 갈리지 않았다.
""" """
import hashlib import hashlib
import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
@ -20,21 +19,18 @@ import httpx
from common.enums import PlaceCategory from common.enums import PlaceCategory
from common.logger import LOG from common.logger import LOG
from services.grounding.copy import FactInput, faq_polarity_ok, ground_check from services.grounding.copy import FactInput, faq_polarity_ok, ground_check
from services.llm.gemini import ( from services.llm import provider
DEFAULT_MODEL as DEFAULT_TEXT_MODEL, from services.llm.errors import LlmError
GeminiError, from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput
GeminiInvalidOutput, from services.llm.errors import LlmNotConfigured as GeminiNotConfigured
GeminiNotConfigured,
Usage,
call,
extract_text,
is_configured,
price,
read_usage,
)
from services.prompts.copy import RESPONSE_SCHEMA, build_prompt 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 @dataclass
class GeneratedFaq: class GeneratedFaq:
question: str question: str
@ -54,6 +50,7 @@ class GeneratedCopy:
meta_description: Optional[str] = None meta_description: Optional[str] = None
faqs: list[GeneratedFaq] = field(default_factory=list) faqs: list[GeneratedFaq] = field(default_factory=list)
rejected: list[tuple[str, str]] = 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]: def _unit_facts(unit_summaries: Optional[list[dict]]) -> list[FactInput]:
@ -100,7 +97,7 @@ async def generate_copy(
records: Optional[list[str]] = None, records: Optional[list[str]] = None,
suggested_questions: Optional[list[str]] = None, suggested_questions: Optional[list[str]] = None,
max_faqs: int = 8, max_faqs: int = 8,
model: str = DEFAULT_TEXT_MODEL, model: Optional[str] = None,
max_retries: int = 2, max_retries: int = 2,
client: Optional[httpx.AsyncClient] = None, client: Optional[httpx.AsyncClient] = None,
) -> GeneratedCopy: ) -> GeneratedCopy:
@ -111,13 +108,15 @@ async def generate_copy(
생성 결과는 전부 ground_check 통과한 것만 담긴다. 통과 항목은 rejected 간다. 생성 결과는 전부 ground_check 통과한 것만 담긴다. 통과 항목은 rejected 간다.
생성 대상 필드는 업종 스키마의 allow_llm=True 것뿐이다(호출측이 필터링해서 넘긴다). 생성 대상 필드는 업종 스키마의 allow_llm=True 것뿐이다(호출측이 필터링해서 넘긴다).
""" """
if not is_configured(): llm = provider.active()
raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다") if not llm.is_configured():
raise GeminiNotConfigured(f"{llm.__name__.rsplit('.', 1)[-1].upper()}_API_KEY 가 설정되지 않았다")
model = model or llm.DEFAULT_MODEL
# ★ 사업장 fact 가 없어도 객실·메뉴 근거가 있으면 쓴다. 요금표만 있는 모텔이 그 경우다 — # ★ 사업장 fact 가 없어도 객실·메뉴 근거가 있으면 쓴다. 요금표만 있는 모텔이 그 경우다 —
# "대실 20,000원" 은 근거 있는 사실이고, 손님이 가장 먼저 묻는 것이기도 하다. # "대실 20,000원" 은 근거 있는 사실이고, 손님이 가장 먼저 묻는 것이기도 하다.
unit_grounding = _unit_facts(unit_summaries) unit_grounding = _unit_facts(unit_summaries)
if not facts and not unit_grounding: 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 가 없다 — 생성하지 않았다")]) return GeneratedCopy(rejected=[("(전체)", "근거 fact 가 없다 — 생성하지 않았다")])
# 검증에 쓸 근거 = 넘겨받은 fact + 객실 요약 + 상호명(상호에 숫자가 있어도 근거로 본다) # 검증에 쓸 근거 = 넘겨받은 fact + 객실 요약 + 상호명(상호에 숫자가 있어도 근거로 본다)
@ -125,31 +124,22 @@ async def generate_copy(
grounding.append(FactInput(key="place_name", label="상호명", value=place_name)) grounding.append(FactInput(key="place_name", label="상호명", value=place_name))
allowed_keys = {f.key for f in facts} | {f.key for f in grounding} allowed_keys = {f.key for f in facts} | {f.key for f in grounding}
body = { prompt = build_prompt(place_name, category, facts, max_faqs, unit_grounding, records, suggested_questions)
"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,
},
}
owns_client = client is None owns_client = client is None
client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0)) client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0))
try: try:
payload = await call(client, model, body, max_retries) llm_result = await llm.generate(
parsed = json.loads(extract_text(payload)) client, model, prompt=prompt, response_schema=RESPONSE_SCHEMA, temperature=0.2, max_retries=max_retries,
except json.JSONDecodeError as ex: )
raise GeminiInvalidOutput(f"구조화 출력 파싱 실패: {ex}") from ex
finally: finally:
if owns_client: if owns_client:
await client.aclose() 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() 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)) result.faqs.append(GeneratedFaq(question=question, answer=answer, fact_keys=keys))
LOG.i( 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"메타 {'O' if result.meta_description else 'X'} · FAQ {len(result.faqs)}건 · "
f"반려 {len(result.rejected)}건 · model={model} · " 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 return result
@ -215,7 +205,7 @@ _SUMMARY_PROMPT = (
async def summarize_text( async def summarize_text(
text: str, text: str,
*, *,
model: str = DEFAULT_TEXT_MODEL, model: Optional[str] = None,
max_retries: int = 2, max_retries: int = 2,
client: Optional[httpx.AsyncClient] = None, client: Optional[httpx.AsyncClient] = None,
) -> Optional[str]: ) -> Optional[str]:
@ -227,8 +217,10 @@ async def summarize_text(
stripped = text.strip() stripped = text.strip()
if not stripped: if not stripped:
return None return None
if not is_configured(): llm = provider.active()
if not llm.is_configured():
return None return None
model = model or llm.DEFAULT_MODEL
# 길이 기준을 바꾼 뒤 이전 길이의 요약을 재사용하지 않도록 프롬프트도 키에 넣는다. # 길이 기준을 바꾼 뒤 이전 길이의 요약을 재사용하지 않도록 프롬프트도 키에 넣는다.
cache_key = hashlib.sha256((_SUMMARY_PROMPT + stripped).encode("utf-8")).hexdigest() cache_key = hashlib.sha256((_SUMMARY_PROMPT + stripped).encode("utf-8")).hexdigest()
@ -236,20 +228,13 @@ async def summarize_text(
if cached is not None: if cached is not None:
return cached return cached
body = {
"contents": [{"role": "user", "parts": [{
"text": _SUMMARY_PROMPT + stripped,
}]}],
"generationConfig": {"temperature": 0.2},
}
owns_client = client is None owns_client = client is None
client = client or httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0)) client = client or httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0))
try: try:
payload = await call(client, model, body, max_retries) result = await llm.generate(client, model, prompt=_SUMMARY_PROMPT + stripped, temperature=0.2, max_retries=max_retries)
summary = extract_text(payload).strip() summary = result.text.strip()
except GeminiError as ex: except LlmError as ex:
LOG.w(f"[gemini-text] 요약 실패: {ex}") LOG.w(f"[llm-text] 요약 실패: {ex}")
return None return None
finally: finally:
if owns_client: if owns_client:
@ -279,7 +264,7 @@ async def generate_song(
region: str, region: str,
grounding: list[str], grounding: list[str],
intro: str = "", intro: str = "",
model: str = DEFAULT_TEXT_MODEL, model: Optional[str] = None,
max_retries: int = 2, max_retries: int = 2,
client: Optional[httpx.AsyncClient] = None, client: Optional[httpx.AsyncClient] = None,
) -> GeneratedSong: ) -> GeneratedSong:
@ -291,47 +276,39 @@ async def generate_song(
재료가 하나도 없으면 부르지 않는다 소개문과 같은 규칙이다. 상호와 지역만으로 노래는 재료가 하나도 없으면 부르지 않는다 소개문과 같은 규칙이다. 상호와 지역만으로 노래는
어느 숙소에 붙여도 말이 되는 노래이고, 그건 기능이 하려던 일이 아니다. 어느 숙소에 붙여도 말이 되는 노래이고, 그건 기능이 하려던 일이 아니다.
""" """
if not is_configured(): llm = provider.active()
raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다") if not llm.is_configured():
raise GeminiNotConfigured("API 키가 설정되지 않았다")
if not grounding and not (intro or "").strip(): if not grounding and not (intro or "").strip():
raise GeminiInvalidOutput("가사를 쓸 재료가 없다 — 확인된 fact 도 소개문도 없다") raise GeminiInvalidOutput("가사를 쓸 재료가 없다 — 확인된 fact 도 소개문도 없다")
model = model or llm.DEFAULT_MODEL
from common.category_schema import get_schema from common.category_schema import get_schema
from services.prompts.song import RESPONSE_SCHEMA as SONG_SCHEMA, build_prompt as build_song_prompt from services.prompts.song import RESPONSE_SCHEMA as SONG_SCHEMA, build_prompt as build_song_prompt
body = { prompt = build_song_prompt(place_name, get_schema(category).label, region, grounding, intro)
"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,
},
}
owns_client = client is None owns_client = client is None
client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0)) client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0))
try: try:
payload = await call(client, model, body, max_retries) llm_result = await llm.generate(
parsed = json.loads(extract_text(payload)) client, model, prompt=prompt, response_schema=SONG_SCHEMA, temperature=0.9, max_retries=max_retries,
except json.JSONDecodeError as ex: )
raise GeminiInvalidOutput(f"가사 파싱 실패: {ex}") from ex
finally: finally:
if owns_client: if owns_client:
await client.aclose() await client.aclose()
parsed = llm_result.json or {}
title = (parsed.get("title") or "").strip() title = (parsed.get("title") or "").strip()
lyrics = (parsed.get("lyrics") or "").strip() lyrics = (parsed.get("lyrics") or "").strip()
style = (parsed.get("style") or "").strip() style = (parsed.get("style") or "").strip()
if not lyrics: if not lyrics:
raise GeminiInvalidOutput("가사가 비어 있다") raise GeminiInvalidOutput("가사가 비어 있다")
usage = read_usage(payload) usage = llm_result.usage
LOG.i( LOG.i(
f"[gemini-text] '{place_name}' 가사 — '{title}' ({style}) · {len(lyrics)}자 · " f"[llm-text] '{place_name}' 가사 — '{title}' ({style}) · {len(lyrics)}자 · "
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 GeneratedSong(title=title or place_name, lyrics=lyrics, style=style or "acoustic ballad") return GeneratedSong(title=title or place_name, lyrics=lyrics, style=style or "acoustic ballad")

View File

@ -30,6 +30,7 @@ from services.llm.perplexity import (
PerplexityNotConfigured, PerplexityNotConfigured,
call, call,
is_configured, is_configured,
read_usage,
) )
from services.prompts.channel_discovery import RESPONSE_SCHEMA, SYSTEM_PROMPT, build_prompt from services.prompts.channel_discovery import RESPONSE_SCHEMA, SYSTEM_PROMPT, build_prompt
@ -126,12 +127,13 @@ async def discover_channels(
) )
# 내부 검색 횟수는 품질·지연 관측값이다. Sonar 과금은 토큰 + 요청 컨텍스트 요금이다. # 내부 검색 횟수는 품질·지연 관측값이다. Sonar 과금은 토큰 + 요청 컨텍스트 요금이다.
usage = payload.get("usage") or {} usage = read_usage(payload)
reasons = result.reason_counts() reasons = result.reason_counts()
reason_text = " ".join(f"{k}{v}" for k, v in sorted(reasons.items())) or "없음" reason_text = " ".join(f"{k}{v}" for k, v in sorted(reasons.items())) or "없음"
LOG.i( LOG.i(
f"[perplexity] '{name}' 검색={searches}회 발견={len(found)} 통과={len(links)} " 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: if searches > SEARCH_COUNT_WARN_THRESHOLD:
LOG.w( LOG.w(

View File

@ -8,7 +8,7 @@
import json import json
from common.logger import LOG 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 from services.prompts.restaurant_search import RESPONSE_SCHEMA, SYSTEM_PROMPT, build_prompt
MAX_RESULTS = 10 MAX_RESULTS = 10
@ -54,4 +54,10 @@ async def search_region_restaurants(
except PerplexityError as ex: except PerplexityError as ex:
LOG.w(f"[restaurant_discovery] '{region_label}' 검색 실패: {ex}") LOG.w(f"[restaurant_discovery] '{region_label}' 검색 실패: {ex}")
return [] 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

View File

@ -126,6 +126,8 @@ async def _generate_one(
courses: list[dict] = [] courses: list[dict] = []
seen: set[frozenset[str]] = set() seen: set[frozenset[str]] = set()
notes: list[str] = [] notes: list[str] = []
total_in = total_out = 0
total_cost = 0.0
for attempt in range(1, MAX_ATTEMPTS + 1): for attempt in range(1, MAX_ATTEMPTS + 1):
try: try:
payload = await perplexity.call(body, client=client) payload = await perplexity.call(body, client=client)
@ -136,6 +138,11 @@ async def _generate_one(
notes.append(f"{attempt}차 호출 실패: {ex}") notes.append(f"{attempt}차 호출 실패: {ex}")
break # 같은 오류가 반복될 걸 재시도로 밀어붙이지 않는다 — 지금까지 모은 것만 쓴다 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( new_courses, dropped = grounding.parse_courses(
payload, duration, place_name, place_lat, place_lng, already_seen=seen) payload, duration, place_name, place_lat, place_lng, already_seen=seen)
notes += dropped notes += dropped
@ -151,7 +158,10 @@ async def _generate_one(
if len(courses) < TARGET_COURSES: if len(courses) < TARGET_COURSES:
notes.append(f"{MAX_ATTEMPTS}차 시도 후에도 {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 return courses, notes

View File

@ -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):
"""응답이 기대한 모양이 아니다."""

View File

@ -8,11 +8,17 @@
여기가 책임지지 않는 : 무엇을 물을지(services/prompts/), 답을 믿을지(services/grounding/). 여기가 책임지지 않는 : 무엇을 물을지(services/prompts/), 답을 믿을지(services/grounding/).
""" """
import asyncio import asyncio
from dataclasses import dataclass import base64
import json
import httpx import httpx
from config.server_configs import external_api_config 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" _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} _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: def is_configured() -> bool:
"""키가 있는지 — 어댑터 등록/스킵 판단용. 예외를 던지지 않는다.""" """키가 있는지 — 어댑터 등록/스킵 판단용. 예외를 던지지 않는다."""
return bool(external_api_config.gemini_api_key) 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 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 out = _PRICE_PER_1M_OUTPUT.get(model, 0.0) * usage.output_tokens / 1_000_000
return round(inp + out, 4) 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))

View File

@ -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)

View File

@ -9,6 +9,7 @@
나중에 환각을 추적할 있게 한다. 나중에 환각을 추적할 있게 한다.
""" """
import json import json
from dataclasses import dataclass
import httpx import httpx
@ -36,6 +37,39 @@ def is_configured() -> bool:
return bool((external_api_config.perplexity_api_key or "").strip()) 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: async def call(body: dict, *, client: httpx.AsyncClient | None = None) -> dict:
"""★ LLM 이 실제로 불리는 지점. chat/completions 1회. """★ LLM 이 실제로 불리는 지점. chat/completions 1회.

View File

@ -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

View File

@ -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

View File

@ -98,7 +98,11 @@ async def research_place(place, place_id: str) -> dict:
return {"error": str(ex)} return {"error": str(ex)}
items, dropped = grounding.parse_items(payload, name, prompts.MAX_ITEMS) 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: if not items:
return {"items": 0, "dropped": dropped} return {"items": 0, "dropped": dropped}

View File

@ -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, dropped = grounding.parse_items(payload, kind, prompts.max_items(kind), region_label)
items = await _attach_images(kind, items, 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 return items, dropped

View File

@ -18,6 +18,7 @@ from config.server_configs import external_api_config
from crud.media_crud import MediaCRUD from crud.media_crud import MediaCRUD
from crud.place_crud import PlaceCRUD from crud.place_crud import PlaceCRUD
from services.external import gemini from services.external import gemini
from services.llm import provider
from common.job_errors import PermanentJobError from common.job_errors import PermanentJobError
_media_crud = MediaCRUD() _media_crud = MediaCRUD()
@ -36,7 +37,7 @@ async def run_vision(job: dict) -> dict:
force = bool(payload.get("force")) force = bool(payload.get("force"))
if not gemini.is_configured(): if not gemini.is_configured():
raise VisionAborted("GEMINI_API_KEY 미설정 — 사진 분석을 할 수 없다") raise VisionAborted("API 키 미설정 — 사진 분석을 할 수 없다")
err, place = await DB_SESSION_MNG.execute_lambda( err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), 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} by_key = {(r.origin_url or r.url): r for r in rows}
threshold = external_api_config.vision_confidence_threshold 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: try:
results = await gemini.analyze_images( results = await gemini.analyze_images(
images, images,
category=PlaceCategory(place.category), category=PlaceCategory(place.category),
unit_names=unit_names, unit_names=unit_names,
model=external_api_config.gemini_vision_model, model=model,
confidence_threshold=threshold, confidence_threshold=threshold,
) )
except gemini.GeminiNotConfigured as ex: except gemini.GeminiNotConfigured as ex:

View File

@ -11,8 +11,6 @@ import {Section} from '@site/lib/ui';
* . * .
* *
* . * .
* "확인 안 된 항목이 N개 있다"
* . .
* *
* (2026-09-03) * (2026-09-03)
* '이용 규정' , (······ * '이용 규정' , (······
@ -63,10 +61,6 @@ export function EssentialInfoSection() {
if (['cooking_allowed', 'smoking'].includes(row.key ?? '')) return false; 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'); 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; if (rows.length === 0 && guides.length === 0) return null;
/** /**
@ -97,14 +91,7 @@ export function EssentialInfoSection() {
title="이용안내 및 예약" title="이용안내 및 예약"
lead="방문 전 확인이 필요한 운영 규정과 시설 안내입니다." lead="방문 전 확인이 필요한 운영 규정과 시설 안내입니다."
footnote={ footnote={
<span className="flex flex-col gap-1 sm:flex-row sm:justify-between"> <span className="font-medium">{formatKoreanDate(payload.site.updatedAt)} </span>
<span>
{hiddenCount > 0
? `확인 중인 항목 ${hiddenCount}개는 표시하지 않았습니다. 필요하시면 전화로 문의해 주세요.`
: '등록 정보와 수집한 안내를 기준으로 표시합니다.'}
</span>
<span className="font-medium">{formatKoreanDate(payload.site.updatedAt)} </span>
</span>
} }
> >
<div className="space-y-8"> <div className="space-y-8">