o2o-site-AEO/solution/backend/config/config_models.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

177 lines
8.1 KiB
Python

"""설정 모델 — 값은 전부 환경변수(최상위 .env 포함)에서 온다.
★ 환경변수 이름은 validation_alias 로 못 박는다. 필드명만 두면 `port` 가 흔한 `PORT` 를
주워 먹어 엉뚱한 포트로 뜬다.
"""
from functools import lru_cache
from typing import Optional
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
import os
# 레포 최상위 .env. 여기서 네 단계 위다 — 세 단계로 두면 solution/.env(없는 파일)를 본다.
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
_DOTENV = os.path.join(_REPO_ROOT, ".env")
APP_ENV = os.environ.get("APP_ENV", "local")
# ★ APP_ENV=test 면 .env 를 읽지 않는다. 실키가 새면 테스트가 실제 외부 API 를 때린다.
_ENV_FILE = None if APP_ENV == "test" else _DOTENV
# ★ 테스트는 별도 DB. conftest 가 "이름에 test 없으면 중단" 으로 dev DB 를 지킨다.
_DEFAULT_DB_NAME = "web4ai_test_db" if APP_ENV == "test" else "web4ai_db"
_BASE = SettingsConfigDict(env_file=_ENV_FILE, env_file_encoding="utf-8", extra="ignore", case_sensitive=False)
class WebServerConfig(BaseSettings):
model_config = _BASE
server_name: str = Field("Web4aiServer", validation_alias="WEB_SERVER_NAME")
port: int = Field(9800, validation_alias="WEB_SERVER_PORT")
process_count: int = Field(1, validation_alias="WEB_PROCESS_COUNT")
is_ssl: bool = Field(False, validation_alias="WEB_IS_SSL")
is_test: bool = Field(False, validation_alias="WEB_IS_TEST")
# CORS 허용 오리진(쉼표로 여럿). vite 는 3000 이 막히면 3001, 3002… 로 옮겨 뜬다.
client_url: str = Field(
"http://localhost:3000,http://localhost:3001,http://localhost:3002,"
"http://localhost:3003,http://localhost:3004,http://localhost:3005",
validation_alias="CLIENT_URL",
)
landing_url: str = Field("", validation_alias="LANDING_URL")
class LogConfig(BaseSettings):
model_config = _BASE
print_console: bool = Field(True, validation_alias="LOG_PRINT_CONSOLE")
log_level: str = Field("debug", validation_alias="LOG_LEVEL")
class MainDBConfig(BaseSettings):
"""DB read/write 분리. 읽기 접속을 안 주면 쓰기와 같은 곳을 본다(복제 없는 환경이 기본)."""
model_config = _BASE
db_type: str = Field("postgresql", validation_alias="DB_TYPE")
name: str = Field(_DEFAULT_DB_NAME, validation_alias="DB_NAME")
write_host: str = Field("127.0.0.1", validation_alias="DB_HOST")
write_port: int = Field(5432, validation_alias="DB_PORT")
write_id: str = Field("postgres", validation_alias="DB_USER")
write_pw: str = Field("", validation_alias="DB_PASSWORD")
read_host: Optional[str] = Field(None, validation_alias="DB_READ_HOST")
read_port: Optional[int] = Field(None, validation_alias="DB_READ_PORT")
read_id: Optional[str] = Field(None, validation_alias="DB_READ_USER")
read_pw: Optional[str] = Field(None, validation_alias="DB_READ_PASSWORD")
show_log: bool = Field(False, validation_alias="DB_SHOW_LOG")
# 동시 커넥션 상한 = (pool_size + max_overflow) x 엔진수(R/W=2) x 워커수.
# PostgreSQL max_connections 를 넘기면 안 된다.
pool_size: int = Field(10, validation_alias="DB_POOL_SIZE")
max_overflow: int = Field(20, validation_alias="DB_MAX_OVERFLOW")
# ""/"disable"=로컬 · "require"|"verify-ca"|"verify-full"=관리형 DB
sslmode: str = Field("", validation_alias="DB_SSLMODE")
@model_validator(mode="after")
def _read_falls_back_to_write(self):
if self.read_host is None:
self.read_host = self.write_host
if self.read_port is None:
self.read_port = self.write_port
if self.read_id is None:
self.read_id = self.write_id
if self.read_pw is None:
self.read_pw = self.write_pw
return self
class JwtToken(BaseSettings):
model_config = _BASE
access_key: str = Field("", validation_alias="JWT_ACCESS_SECRET")
refresh_key: str = Field("", validation_alias="JWT_REFRESH_SECRET")
access_expire_min: int = Field(30, validation_alias="JWT_ACCESS_EXPIRE_MIN")
refresh_expire_day: int = Field(7, validation_alias="JWT_REFRESH_EXPIRE_DAY")
class GoogleOAuthConfig(BaseSettings):
"""구글 로그인. client_id 가 비면 그 로그인 수단만 꺼진다 — 다른 외부 키들과 같은 규칙이다.
★ client_id 는 비밀이 아니다(프론트 번들에 그대로 들어간다). 서버가 이 값을 갖는 이유는
숨기려는 게 아니라 **수신자(aud) 대조** 때문이다 — 남의 앱에 발급된 구글 토큰을 그대로
들고 와도 우리 계정이 되지 않게 막는 유일한 검사다.
★ client_secret 은 쓰지 않는다. 프론트가 ID 토큰을 받아 오는 방식(GIS)이라 코드 교환이 없다."""
model_config = _BASE
client_id: str = Field("", validation_alias="GOOGLE_CLIENT_ID")
class ExternalApiConfig(BaseSettings):
"""키가 비면 그 어댑터만 비활성이다 — 부팅이 외부 계약에 묶이면 안 된다."""
model_config = _BASE
perplexity_api_key: str = Field("", validation_alias="PERPLEXITY_API_KEY")
# 동일 업소 검증 — 둘 다 있으면 카카오 우선.
# 카카오: 15건 · 전화번호 O · 고유 id O · 행정구역 코드 O
# 네이버: 5건 · 전화번호 X · 고유 id 불확실 · 행정구역 코드 X → AMBIGUOUS 가 는다
kakao_rest_api_key: str = Field("", validation_alias="KAKAO_REST_API_KEY")
naver_client_id: str = Field("", validation_alias="NAVER_CLIENT_ID")
naver_client_secret: str = Field("", validation_alias="NAVER_CLIENT_SECRET")
gemini_api_key: str = Field("", validation_alias="GEMINI_API_KEY")
# 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")
# 발행할 때 이 숙소의 노래를 한 곡 만든다(services/song_service). 비면 그 단계만 건너뛴다.
suno_api_key: str = Field("", validation_alias="SUNO_API_KEY")
# ★ 콜백은 쓰지 않고 폴링한다 — 우리 백엔드는 로컬·사내망이라 Suno 가 부를 수 있는 주소가 아니다.
# 그래도 API 가 필수로 요구하는 필드라 값을 들고 있는다(services/external/suno.py 주석).
suno_callback_url: str = Field("", validation_alias="SUNO_CALLBACK_URL")
# 발행 사이트 메타 키워드(keywords · 제목)를 받아 오는 사내 서비스(o2o-site-ontology). 비면 그 단계만
# 건너뛴다 — 제목·메타가 예전 그대로 나간다(services/seo_keywords).
site_ontology_url: str = Field("", validation_alias="SITE_ONTOLOGY_URL")
# .env 를 요청마다 다시 읽지 않는다. 새 코드는 Depends(get_*) 로 주입받는다.
@lru_cache
def get_web_server_config() -> WebServerConfig:
return WebServerConfig()
@lru_cache
def get_log_config() -> LogConfig:
return LogConfig()
@lru_cache
def get_main_db_config() -> MainDBConfig:
return MainDBConfig()
@lru_cache
def get_jwt_token_config() -> JwtToken:
return JwtToken()
@lru_cache
def get_external_api_config() -> ExternalApiConfig:
return ExternalApiConfig()
@lru_cache
def get_google_oauth_config() -> GoogleOAuthConfig:
return GoogleOAuthConfig()