o2o-site-AEO/solution/backend/services/external/gemini.py
김성경 dbae2d4f35 [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>
2026-09-16 17:30:11 +09:00

251 lines
9.8 KiB
Python

"""사진 라벨·접근성 alt 생성 — 겹들을 엮어 결과를 만드는 자리.
이 파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다:
무엇을 묻는가 services/prompts/vision.py 프롬프트·응답 스키마
어떻게 부르는가 services/llm/provider.py 공급자 선택(gemini/openai) · HTTP·재시도·토큰·비용
무엇을 돌려주는가 여기 배치 나누기 → 호출 → ref 매칭 → 조립
결과는 순서가 아니라 `ref` 로 매칭하고, 신뢰도가 낮으면 사람 확인 대상으로 남긴다.
(문장 생성과 달리 여기엔 grounding 겹이 없다 — 사진 설명은 대조할 fact 가 없고,
대신 신뢰도 임계값과 사람 확인 큐가 그 몫을 한다.)
"""
from dataclasses import dataclass, field
from typing import Optional
import httpx
from common.enums import PlaceCategory
from common.logger import LOG
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"),
(b"\xff\xd8\xff", "image/jpeg"),
(b"GIF87a", "image/gif"),
(b"GIF89a", "image/gif"),
)
@dataclass
class ImageInput:
"""분석할 사진 1장. origin_url 이 결과 매칭 키다(place.media.origin_url 과 같은 값)."""
origin_url: str
data: Optional[bytes] = None # 이미 받아둔 바이트. 없으면 fetch_url 에서 내려받는다
fetch_url: Optional[str] = None # 내려받을 주소. 비면 origin_url 을 쓴다
mime_type: Optional[str] = None # 없으면 시그니처로 판별
unit_name_hint: Optional[str] = None # "A동 스탠다드" 같은 힌트가 있으면 라벨에 반영
@dataclass
class VisionResult:
"""사진 1장의 분석 결과. 입력과 1:1 로 대응하며 실패해도 자리를 지킨다."""
origin_url: str
label: Optional[str] = None
alt_text: Optional[str] = None
confidence: float = 0.0
needs_review: bool = True # ★ 기본이 '사람 확인 필요'다. 확신이 있을 때만 내려간다
ok: bool = False
error: Optional[str] = None
@dataclass
class _Usage:
input_tokens: int = 0
output_tokens: int = 0
batches: int = 0
failed_batches: int = 0
by_model: dict = field(default_factory=dict)
def _sniff_mime(data: bytes) -> str:
for magic, mime in _MAGIC:
if data.startswith(magic):
return mime
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp"
return "image/jpeg" # 판별 실패 시 가장 흔한 형식으로 시도한다
async def _load_bytes(client: httpx.AsyncClient, image: ImageInput) -> bytes:
"""이미지 바이트 확보. 이미 있으면 그대로, 없으면 내려받는다.
직접 내려받는 이유: 타임아웃을 우리가 통제하고, 사진별 실패를 개별로 보고하기 위해서다."""
if image.data:
return image.data
url = image.fetch_url or image.origin_url
resp = await client.get(url, follow_redirects=True)
if resp.status_code != 200:
raise GeminiError(f"이미지 내려받기 실패 status={resp.status_code}")
return resp.content
async def _run_batch(
client: httpx.AsyncClient,
batch: list[ImageInput],
*,
model: str,
category: Optional[PlaceCategory],
unit_names: Optional[list[str]],
confidence_threshold: float,
max_retries: int,
usage: _Usage,
) -> dict[str, VisionResult]:
"""배치 1개 처리. 반환 {origin_url: VisionResult}.
★ 배치가 통째로 실패해도 예외를 밖으로 던지지 않는다 — 호출측이 나머지 배치를 계속 돌려야 한다."""
out: dict[str, VisionResult] = {}
ref_map: dict[str, ImageInput] = {}
images_payload: list[ImagePart] = []
for i, image in enumerate(batch):
ref = f"img-{i}"
try:
data = await _load_bytes(client, image)
except Exception as ex:
# 이 사진만 실패. 배치의 나머지는 그대로 보낸다.
out[image.origin_url] = VisionResult(
origin_url=image.origin_url, ok=False, needs_review=True,
error=f"이미지 로드 실패: {type(ex).__name__}: {ex}",
)
continue
ref_map[ref] = image
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
prompt = build_prompt(category, unit_names, sorted(ref_map))
llm_provider = provider.active()
usage.batches += 1
try:
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"[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,
error=f"{type(ex).__name__}: {str(ex)[:200]}",
)
return out
batch_usage = llm_result.usage
usage.input_tokens += batch_usage.input_tokens
usage.output_tokens += batch_usage.output_tokens
# ★ 순서가 아니라 ref 로 매칭한다.
seen: set[str] = set()
for item in parsed.get("items") or []:
ref = str(item.get("ref", "")).strip()
image = ref_map.get(ref)
if image is None:
continue # 모델이 없는 ref 를 지어냈다 — 버린다
seen.add(ref)
try:
confidence = float(item.get("confidence") or 0.0)
except (TypeError, ValueError):
confidence = 0.0
confidence = max(0.0, min(1.0, confidence))
label = (item.get("label") or "").strip() or None
alt = (item.get("alt_text") or "").strip() or None
out[image.origin_url] = VisionResult(
origin_url=image.origin_url,
label=label,
alt_text=alt,
confidence=confidence,
# ★ 신뢰도 미달이거나 라벨/alt 가 비면 자동 반영하지 않는다.
needs_review=confidence < confidence_threshold or not label or not alt,
ok=True,
)
# 응답에서 빠진 사진 — 조용히 사라지지 않게 자리를 채운다.
for ref, image in ref_map.items():
if ref not in seen:
out[image.origin_url] = VisionResult(
origin_url=image.origin_url, ok=False, needs_review=True,
error="응답에 해당 ref 가 없다",
)
return out
async def analyze_images(
images: list[ImageInput],
*,
category: Optional[PlaceCategory] = None,
unit_names: Optional[list[str]] = None,
model: Optional[str] = None,
batch_size: int = 10,
confidence_threshold: float = 0.7,
max_retries: int = 2,
client: Optional[httpx.AsyncClient] = None,
) -> list[VisionResult]:
"""사진들을 분류하고 alt 를 만든다.
★ 반환 길이는 항상 입력과 같다. 실패분도 ok=False 로 자리를 지킨다 —
호출측이 길이나 순서로 매칭하다 어긋나면 엉뚱한 사진에 alt 가 붙는다.
★ needs_review=True 인 항목은 자동 반영하지 말고 사람 확인 큐(MediaStatus.PENDING_REVIEW)로 보낸다.
"""
llm_provider = provider.active()
if not llm_provider.is_configured():
raise GeminiNotConfigured("API 키가 설정되지 않았다")
model = model or llm_provider.DEFAULT_MODEL
if not images:
return []
owns_client = client is None
# 사진 여러 장을 배치로 보내므로 타임아웃을 넉넉히 잡는다.
client = client or httpx.AsyncClient(timeout=httpx.Timeout(180.0, connect=10.0))
usage = _Usage()
merged: dict[str, VisionResult] = {}
try:
for start in range(0, len(images), max(1, batch_size)):
batch = images[start:start + max(1, batch_size)]
merged.update(await _run_batch(
client, batch,
model=model, category=category, unit_names=unit_names,
confidence_threshold=confidence_threshold, max_retries=max_retries, usage=usage,
))
finally:
if owns_client:
await client.aclose()
results = [
merged.get(img.origin_url)
or VisionResult(origin_url=img.origin_url, ok=False, needs_review=True, error="결과 누락")
for img in 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"[vision] 사진분석 {len(results)}장 (성공 {ok} · 확인필요 {review}) · "
f"배치 {usage.batches}(실패 {usage.failed_batches}) · model={model} · "
f"tokens in={usage.input_tokens} out={usage.output_tokens} · "
f"약 ${llm_provider.price(model, Usage(usage.input_tokens, usage.output_tokens))}"
)
return results