## 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>
132 lines
6.3 KiB
Python
132 lines
6.3 KiB
Python
"""업소 조사 — 소개문을 쓸 **재료**를 공개 웹에서 찾아 근거 자리에 넣는다.
|
|
|
|
프롬프트 services/prompts/place_research.py 무엇을 묻나
|
|
호출 services/llm/perplexity.py HTTP·타임아웃·인증
|
|
믿을 것인가 services/grounding/place_research.py 출처 필수 · 상호 대조
|
|
여기 조사 → 근거 적재 (문장은 쓰지 않는다)
|
|
|
|
★ 이 모듈은 **문장을 쓰지 않는다.** 소개문은 지금처럼 `copy_service`(Gemini)가 쓴다.
|
|
여기가 하는 일은 그 생성기에게 줄 재료를 늘리는 것뿐이다. 왜 나누나 —
|
|
소개문을 검색모델에게 바로 시키면 그 문장의 근거를 우리가 갖지 못하고,
|
|
`ground_check` 가 근거 없는 문장을 전부 반려해 결국 앙상해진다.
|
|
|
|
★ 왜 필요했나 (실측 2026-09-10, 스테이,머뭄)
|
|
근거 fact 9건으로 생성한 소개문은 "군산시에 있는 스테이,머뭄입니다. 주차 가능." 이었다.
|
|
네이버 플레이스 3건 · TourAPI 미등록 · 예약 페이지와 인스타는 robots 금지 — 남은 공개
|
|
출처가 없어서지 생성기 잘못이 아니었다. 그런데 이 업소의 내력(1920년대 고택, 히로쓰 가옥
|
|
옆, 2024년 리모델링, A동·B동)은 블로그·기사에 있다. 그걸 출처와 함께 가져온다.
|
|
|
|
★ 요금은 조사하지 않는다. 블로그의 요금은 대개 옛값이고, 틀리면 예약 클레임이다.
|
|
fact 는 fact 경로(수집·사장님 확인)로만 들어온다 — 이 모듈은 `place_facts` 를 쓰지 않는다.
|
|
|
|
★ 적재 자리: `place_channels.raw` — `copy_service` 가 이미 '수집 원문'을 읽는 그 자리다.
|
|
새 표를 만들지 않는다. 대신 **확정하지 않는다**(`confirmed_at` NULL) — 조사 출처는
|
|
이 업소의 공식 채널이 아니라 남이 쓴 글이다. 발행본의 공식 채널·sameAs 에 나가면 안 된다.
|
|
"""
|
|
import uuid
|
|
|
|
import httpx
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.database.model.models import place_channels
|
|
from common.enums import ErrorType, LinkChannel, SourceType
|
|
from common.logger import LOG
|
|
from common.utils.gtime import GTime
|
|
from crud.place_crud import PlaceCRUD
|
|
from services.grounding import place_research as grounding
|
|
from services.llm import perplexity
|
|
from services.prompts import place_research as prompts
|
|
|
|
_place_crud = PlaceCRUD()
|
|
|
|
# 검색을 동반해 느리다. 지역 이야기와 같은 값을 쓴다(실측 건당 9~15초).
|
|
_TIMEOUT = 120.0
|
|
|
|
# ★ raw 봉투의 표식. `copy_service` 가 "확정되지 않았지만 근거로는 읽어도 되는 글" 을
|
|
# 이 값으로 가른다. 크롤 원문(확정 채널)과 섞이지 않게 이름을 붙여 둔다.
|
|
RAW_KIND = "research"
|
|
|
|
|
|
def _envelope(items: list[dict]) -> dict:
|
|
"""근거 봉투. text 는 `copy_service` 가 그대로 읽고, sources 는 추적용으로 남긴다.
|
|
|
|
★ 문장 뒤에 출처를 붙여 한 덩어리로 만든다 — 생성기가 문장만 보고 쓰더라도,
|
|
나중에 "이 소개문의 이 대목은 어디서 왔나" 를 raw 만 열어 보면 알 수 있어야 한다.
|
|
"""
|
|
return {
|
|
"kind": RAW_KIND,
|
|
"text": "\n".join(f"- {row['text']} (출처: {row['source']['name']})" for row in items),
|
|
"sources": [row["source"] for row in items],
|
|
"collected_at": GTime.UTC().isoformat(),
|
|
}
|
|
|
|
|
|
async def research_place(place, place_id: str) -> dict:
|
|
"""업소 하나를 조사해 근거를 적재한다. 채택 건수와 버린 이유를 돌려준다.
|
|
|
|
실패는 예외로 올리지 않는다 — 조사가 없어도 발행은 되어야 한다(재료가 적을 뿐이다).
|
|
"""
|
|
name = (getattr(place, "name", None) or "").strip()
|
|
address = (getattr(place, "road_address", None) or getattr(place, "address", None) or "").strip()
|
|
if not name or not address:
|
|
return {"skipped": "상호·주소를 모른다"}
|
|
if not perplexity.is_configured():
|
|
return {"skipped": "PERPLEXITY_API_KEY 미설정"}
|
|
|
|
# 업종 이름은 업종 스키마가 단일 출처다(`common/category_schema`) — 여기에 표를 또 적으면
|
|
# 업종이 늘 때 한쪽만 늘어난다.
|
|
from common.category_schema import get_schema
|
|
|
|
category_label = get_schema(place.category).label
|
|
|
|
body = {
|
|
"model": perplexity.DEFAULT_MODEL,
|
|
"messages": [
|
|
{"role": "system", "content": prompts.SYSTEM_PROMPT},
|
|
{"role": "user", "content": prompts.build_prompt(name, address, category_label)},
|
|
],
|
|
"max_tokens": perplexity.DEFAULT_MAX_TOKENS,
|
|
}
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
|
payload = await perplexity.call(body, client=client)
|
|
except perplexity.PerplexityNotConfigured:
|
|
return {"skipped": "PERPLEXITY_API_KEY 미설정"}
|
|
except perplexity.PerplexityError as ex:
|
|
LOG.w(f"[research] 호출 실패 place={place_id}: {ex}")
|
|
return {"error": str(ex)}
|
|
|
|
items, dropped = grounding.parse_items(payload, name, prompts.MAX_ITEMS)
|
|
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}
|
|
|
|
# 출처 주소 하나를 대표로 링크에 단다 — 없는 URL 을 만들지 않기 위해 첫 출처를 쓴다.
|
|
url = items[0]["source"]["url"]
|
|
row = place_channels(
|
|
link_id=uuid.uuid4(),
|
|
place_id=uuid.UUID(place_id),
|
|
channel=LinkChannel.ETC.value,
|
|
url=url,
|
|
title=f"{name} 조사 근거",
|
|
discovered_by=SourceType.API.value,
|
|
discovered_at=GTime.UTC(),
|
|
raw=_envelope(items),
|
|
)
|
|
err = await DB_SESSION_MNG.execute_lambda_run(
|
|
[place_channels.DBType()], [lambda s: _place_crud.add_link(s, row)],
|
|
)
|
|
if err != ErrorType.SUCCESS:
|
|
# 이미 같은 URL 이 있으면 raw 만 갱신한다 — 재조사가 행을 늘리면 안 된다.
|
|
await DB_SESSION_MNG.execute_lambda_claim(
|
|
place_channels.DBType(),
|
|
lambda s: _place_crud.set_link_raw(s, uuid.UUID(place_id), url, _envelope(items)),
|
|
)
|
|
|
|
return {"items": len(items), "dropped": dropped, "source": url}
|