o2o-site-AEO/solution/backend/services/external/gemini_extract.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

123 lines
5.1 KiB
Python

"""원문 텍스트 → fact 후보 추출 — 겹들을 엮어 결과를 만드는 자리.
이 파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다:
무엇을 묻는가 services/prompts/extract.py 프롬프트·응답 스키마
어떻게 부르는가 services/llm/provider.py 공급자 선택(gemini/openai) · HTTP·재시도·토큰·비용
답을 믿을 것인가 services/grounding/extract.py evidence 원문 대조
무엇을 돌려주는가 여기 호출 → 검증 → CollectedFact 조립
★ 입력이 무엇이든 여기로 모인다
사장님이 붙여넣은 텍스트도, static_html 어댑터가 받아온 페이지 본문도
똑같이 '원문 문자열' 하나다. 도메인마다 파서를 짜는 대신 여기 한 곳을 쓴다.
★ 나가는 값은 전부 후보다
통과한 fact 도 UNVERIFIED 로 들어간다. 사장님이 확인해야 사이트에 나간다 —
그 게이트는 fact 계층이 담당한다. 여기서는 '원문에 있었다' 까지만 보장한다.
"""
from dataclasses import dataclass, field
from typing import Optional
import httpx
from common.category_schema import get_schema
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 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 는 없고,
# 호출비만 나간다.
MIN_SOURCE_CHARS = 80
@dataclass
class ExtractResult:
"""추출 결과. facts 는 검증을 통과한 것만 담긴다.
rejected 에는 (항목, 사유) 가 들어간다 — 조용히 버리지 않는다.
운영자가 "왜 체크인 시간이 안 들어왔나" 를 이 목록으로 읽는다.
"""
facts: list[CollectedFact] = field(default_factory=list)
rejected: list[tuple[str, str]] = field(default_factory=list)
@property
def ok(self) -> bool:
return bool(self.facts)
async def extract_facts(
place_name: str,
category: PlaceCategory,
source_text: str,
*,
source_url: str,
model: Optional[str] = None,
max_retries: int = 2,
client: Optional[httpx.AsyncClient] = None,
) -> ExtractResult:
"""원문 텍스트에서 업종 스키마 fact 를 뽑는다.
★ source_url 은 필수다. 출처 없는 fact 는 FACT_SOURCE_REQUIRED 로 거부되므로
여기서 구조적으로 찍어 둔다(사장님 붙여넣기면 'owner:paste' 같은 식별자라도 넣는다).
★ 원문이 짧으면 **API 를 호출하지 않는다** — 근거가 없는데 부르면 그게 곧 환각 유발이다.
"""
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"[extract] '{place_name}' 원문 {len(text)}자 — 짧아서 호출하지 않는다")
return ExtractResult(rejected=[("(전체)", f"원문이 {len(text)}자로 너무 짧다 — 호출하지 않았다")])
owns_client = client is None
client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0))
try:
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 = llm_result.json.get("facts") if llm_result.json else None
if not isinstance(rows, list):
raise GeminiInvalidOutput(f"facts 가 배열이 아니다: {type(rows).__name__}")
# ★ 여기가 관문이다. 모델이 뭘 적어 냈든 원문과 대조해서 통과한 것만 나간다.
passed, rejected = verify(rows, source_text=text, schema=get_schema(category))
facts = [
CollectedFact(
key=row["key"],
value=row["value"],
scope=row["scope"],
unit_name=row["unit_name"],
source_url=source_url,
)
for row in passed
]
usage = llm_result.usage
LOG.i(
f"[extract] '{place_name}' 추출 {len(rows)}건 → 통과 {len(facts)}건 · "
f"반려 {len(rejected)}건 · model={model} · "
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"[extract] 반려 {label}{why}")
return ExtractResult(facts=facts, rejected=rejected)