[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:
parent
33d27980c4
commit
dbae2d4f35
@ -33,7 +33,7 @@ NAVER_CLIENT_ID=
|
||||
NAVER_CLIENT_SECRET=
|
||||
# 미발급. 없으면 네이버 지역검색을 쓴다
|
||||
KAKAO_REST_API_KEY=
|
||||
GEMINI_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
# 디코딩된 키(인코딩 키는 이중 인코딩된다)
|
||||
TOUR_API_KEY=
|
||||
# 발행할 때 이 숙소의 노래를 한 곡 만든다(가사 Gemini → 작곡 Suno).
|
||||
|
||||
@ -128,6 +128,10 @@ class ExternalApiConfig(BaseSettings):
|
||||
# 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")
|
||||
|
||||
@ -37,6 +37,7 @@ from crud.place_crud import PlaceCRUD
|
||||
from router.v1.fact.protocol import Req_UpsertFact
|
||||
from services import faq_fill, place_research
|
||||
from services.external import gemini_text
|
||||
from services.llm import provider
|
||||
from services.fact_service import FactService
|
||||
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:
|
||||
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:
|
||||
return await gemini_text.generate_copy(
|
||||
inputs.place.name,
|
||||
@ -190,7 +196,7 @@ async def generate_copy(inputs: CopyInputs) -> gemini_text.GeneratedCopy:
|
||||
records=inputs.records or None,
|
||||
suggested_questions=faq_fill.suggested_questions(inputs.catalog, inputs.known_fact_keys) if inputs.catalog else None,
|
||||
max_faqs=faq_fill.FAQ_TARGET,
|
||||
model=external_api_config.gemini_text_model,
|
||||
model=model,
|
||||
)
|
||||
except gemini_text.GeminiNotConfigured as 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,
|
||||
Req_UpsertFact(
|
||||
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:
|
||||
|
||||
70
solution/backend/services/external/gemini.py
vendored
70
solution/backend/services/external/gemini.py
vendored
@ -3,15 +3,13 @@
|
||||
이 파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다:
|
||||
|
||||
무엇을 묻는가 services/prompts/vision.py 프롬프트·응답 스키마
|
||||
어떻게 부르는가 services/llm/gemini.py HTTP·재시도·토큰·비용
|
||||
어떻게 부르는가 services/llm/provider.py 공급자 선택(gemini/openai) · HTTP·재시도·토큰·비용
|
||||
무엇을 돌려주는가 여기 배치 나누기 → 호출 → ref 매칭 → 조립
|
||||
|
||||
결과는 순서가 아니라 `ref` 로 매칭하고, 신뢰도가 낮으면 사람 확인 대상으로 남긴다.
|
||||
(문장 생성과 달리 여기엔 grounding 겹이 없다 — 사진 설명은 대조할 fact 가 없고,
|
||||
대신 신뢰도 임계값과 사람 확인 큐가 그 몫을 한다.)
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
@ -19,20 +17,19 @@ import httpx
|
||||
|
||||
from common.enums import PlaceCategory
|
||||
from common.logger import LOG
|
||||
from services.llm.gemini import (
|
||||
DEFAULT_MODEL,
|
||||
GeminiError,
|
||||
GeminiInvalidOutput,
|
||||
GeminiNotConfigured,
|
||||
Usage,
|
||||
call,
|
||||
extract_text,
|
||||
is_configured,
|
||||
price,
|
||||
read_usage,
|
||||
)
|
||||
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"),
|
||||
@ -113,7 +110,7 @@ async def _run_batch(
|
||||
★ 배치가 통째로 실패해도 예외를 밖으로 던지지 않는다 — 호출측이 나머지 배치를 계속 돌려야 한다."""
|
||||
out: dict[str, VisionResult] = {}
|
||||
ref_map: dict[str, ImageInput] = {}
|
||||
parts: list[dict] = [{"text": ""}] # 자리를 잡아두고 프롬프트는 아래에서 채운다
|
||||
images_payload: list[ImagePart] = []
|
||||
|
||||
for i, image in enumerate(batch):
|
||||
ref = f"img-{i}"
|
||||
@ -127,33 +124,27 @@ async def _run_batch(
|
||||
)
|
||||
continue
|
||||
ref_map[ref] = image
|
||||
parts.append({"text": f"[{ref}]" + (f" (힌트: {image.unit_name_hint})" if image.unit_name_hint else "")})
|
||||
parts.append({
|
||||
"inline_data": {"mime_type": image.mime_type or _sniff_mime(data), "data": base64.b64encode(data).decode()}
|
||||
})
|
||||
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
|
||||
|
||||
parts[0] = {"text": build_prompt(category, unit_names, sorted(ref_map))}
|
||||
body = {
|
||||
"contents": [{"role": "user", "parts": parts}],
|
||||
"generationConfig": {
|
||||
"responseMimeType": "application/json",
|
||||
"responseSchema": RESPONSE_SCHEMA,
|
||||
"temperature": 0,
|
||||
},
|
||||
}
|
||||
prompt = build_prompt(category, unit_names, sorted(ref_map))
|
||||
llm_provider = provider.active()
|
||||
|
||||
usage.batches += 1
|
||||
try:
|
||||
payload = await call(client, model, body, max_retries)
|
||||
parsed = json.loads(extract_text(payload))
|
||||
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"[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():
|
||||
out[image.origin_url] = VisionResult(
|
||||
origin_url=image.origin_url, ok=False, needs_review=True,
|
||||
@ -161,7 +152,7 @@ async def _run_batch(
|
||||
)
|
||||
return out
|
||||
|
||||
batch_usage = read_usage(payload)
|
||||
batch_usage = llm_result.usage
|
||||
usage.input_tokens += batch_usage.input_tokens
|
||||
usage.output_tokens += batch_usage.output_tokens
|
||||
|
||||
@ -205,7 +196,7 @@ async def analyze_images(
|
||||
*,
|
||||
category: Optional[PlaceCategory] = None,
|
||||
unit_names: Optional[list[str]] = None,
|
||||
model: str = DEFAULT_MODEL,
|
||||
model: Optional[str] = None,
|
||||
batch_size: int = 10,
|
||||
confidence_threshold: float = 0.7,
|
||||
max_retries: int = 2,
|
||||
@ -217,8 +208,10 @@ async def analyze_images(
|
||||
호출측이 길이나 순서로 매칭하다 어긋나면 엉뚱한 사진에 alt 가 붙는다.
|
||||
★ needs_review=True 인 항목은 자동 반영하지 말고 사람 확인 큐(MediaStatus.PENDING_REVIEW)로 보낸다.
|
||||
"""
|
||||
if not is_configured():
|
||||
raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다")
|
||||
llm_provider = provider.active()
|
||||
if not llm_provider.is_configured():
|
||||
raise GeminiNotConfigured("API 키가 설정되지 않았다")
|
||||
model = model or llm_provider.DEFAULT_MODEL
|
||||
if not images:
|
||||
return []
|
||||
|
||||
@ -249,8 +242,9 @@ async def analyze_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"[gemini] 사진분석 {len(results)}장 (성공 {ok} · 확인필요 {review}) · "
|
||||
f"[vision] 사진분석 {len(results)}장 (성공 {ok} · 확인필요 {review}) · "
|
||||
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
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
이 파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다:
|
||||
|
||||
무엇을 묻는가 services/prompts/extract.py 프롬프트·응답 스키마
|
||||
어떻게 부르는가 services/llm/gemini.py HTTP·재시도·토큰·비용
|
||||
어떻게 부르는가 services/llm/provider.py 공급자 선택(gemini/openai) · HTTP·재시도·토큰·비용
|
||||
답을 믿을 것인가 services/grounding/extract.py evidence 원문 대조
|
||||
무엇을 돌려주는가 여기 호출 → 검증 → CollectedFact 조립
|
||||
|
||||
@ -15,7 +15,6 @@
|
||||
통과한 fact 도 UNVERIFIED 로 들어간다. 사장님이 확인해야 사이트에 나간다 —
|
||||
그 게이트는 fact 계층이 담당한다. 여기서는 '원문에 있었다' 까지만 보장한다.
|
||||
"""
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
@ -26,16 +25,9 @@ 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.gemini import (
|
||||
DEFAULT_MODEL,
|
||||
GeminiInvalidOutput,
|
||||
GeminiNotConfigured,
|
||||
call,
|
||||
extract_text,
|
||||
is_configured,
|
||||
price,
|
||||
read_usage,
|
||||
)
|
||||
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 는 없고,
|
||||
@ -65,7 +57,7 @@ async def extract_facts(
|
||||
source_text: str,
|
||||
*,
|
||||
source_url: str,
|
||||
model: str = DEFAULT_MODEL,
|
||||
model: Optional[str] = None,
|
||||
max_retries: int = 2,
|
||||
client: Optional[httpx.AsyncClient] = None,
|
||||
) -> ExtractResult:
|
||||
@ -75,38 +67,31 @@ async def extract_facts(
|
||||
여기서 구조적으로 찍어 둔다(사장님 붙여넣기면 'owner:paste' 같은 식별자라도 넣는다).
|
||||
★ 원문이 짧으면 **API 를 호출하지 않는다** — 근거가 없는데 부르면 그게 곧 환각 유발이다.
|
||||
"""
|
||||
if not is_configured():
|
||||
raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다")
|
||||
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"[gemini-extract] '{place_name}' 원문 {len(text)}자 — 짧아서 호출하지 않는다")
|
||||
LOG.i(f"[extract] '{place_name}' 원문 {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
|
||||
client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0))
|
||||
try:
|
||||
payload = await call(client, model, body, max_retries)
|
||||
parsed = json.loads(extract_text(payload))
|
||||
except json.JSONDecodeError as ex:
|
||||
raise GeminiInvalidOutput(f"구조화 출력 파싱 실패: {ex}") from ex
|
||||
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 = parsed.get("facts")
|
||||
rows = llm_result.json.get("facts") if llm_result.json else None
|
||||
if not isinstance(rows, list):
|
||||
raise GeminiInvalidOutput(f"facts 가 배열이 아니다: {type(rows).__name__}")
|
||||
|
||||
@ -124,14 +109,14 @@ async def extract_facts(
|
||||
for row in passed
|
||||
]
|
||||
|
||||
usage = read_usage(payload)
|
||||
usage = llm_result.usage
|
||||
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"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:
|
||||
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)
|
||||
|
||||
117
solution/backend/services/external/gemini_text.py
vendored
117
solution/backend/services/external/gemini_text.py
vendored
@ -3,7 +3,7 @@
|
||||
이 파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다:
|
||||
|
||||
무엇을 묻는가 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
|
||||
무엇을 돌려주는가 여기 근거 모으기 → 호출 → 검증 → 조립
|
||||
|
||||
@ -11,7 +11,6 @@
|
||||
어디를 봐야 할지가 파일 안에서 갈리지 않았다.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
@ -20,21 +19,18 @@ import httpx
|
||||
from common.enums import PlaceCategory
|
||||
from common.logger import LOG
|
||||
from services.grounding.copy import FactInput, faq_polarity_ok, ground_check
|
||||
from services.llm.gemini import (
|
||||
DEFAULT_MODEL as DEFAULT_TEXT_MODEL,
|
||||
GeminiError,
|
||||
GeminiInvalidOutput,
|
||||
GeminiNotConfigured,
|
||||
Usage,
|
||||
call,
|
||||
extract_text,
|
||||
is_configured,
|
||||
price,
|
||||
read_usage,
|
||||
)
|
||||
from services.llm import provider
|
||||
from services.llm.errors import LlmError
|
||||
from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput
|
||||
from services.llm.errors import LlmNotConfigured as GeminiNotConfigured
|
||||
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
|
||||
class GeneratedFaq:
|
||||
question: str
|
||||
@ -54,6 +50,7 @@ class GeneratedCopy:
|
||||
meta_description: Optional[str] = None
|
||||
faqs: list[GeneratedFaq] = 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]:
|
||||
@ -100,7 +97,7 @@ async def generate_copy(
|
||||
records: Optional[list[str]] = None,
|
||||
suggested_questions: Optional[list[str]] = None,
|
||||
max_faqs: int = 8,
|
||||
model: str = DEFAULT_TEXT_MODEL,
|
||||
model: Optional[str] = None,
|
||||
max_retries: int = 2,
|
||||
client: Optional[httpx.AsyncClient] = None,
|
||||
) -> GeneratedCopy:
|
||||
@ -111,13 +108,15 @@ async def generate_copy(
|
||||
★ 생성 결과는 전부 ground_check 를 통과한 것만 담긴다. 통과 못 한 항목은 rejected 로 간다.
|
||||
★ 생성 대상 필드는 업종 스키마의 allow_llm=True 인 것뿐이다(호출측이 필터링해서 넘긴다).
|
||||
"""
|
||||
if not is_configured():
|
||||
raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다")
|
||||
llm = provider.active()
|
||||
if not llm.is_configured():
|
||||
raise GeminiNotConfigured(f"{llm.__name__.rsplit('.', 1)[-1].upper()}_API_KEY 가 설정되지 않았다")
|
||||
model = model or llm.DEFAULT_MODEL
|
||||
# ★ 사업장 fact 가 없어도 객실·메뉴 근거가 있으면 쓴다. 요금표만 있는 모텔이 그 경우다 —
|
||||
# "대실 20,000원" 은 근거 있는 사실이고, 손님이 가장 먼저 묻는 것이기도 하다.
|
||||
unit_grounding = _unit_facts(unit_summaries)
|
||||
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 가 없다 — 생성하지 않았다")])
|
||||
|
||||
# 검증에 쓸 근거 = 넘겨받은 fact + 객실 요약 + 상호명(상호에 숫자가 있어도 근거로 본다)
|
||||
@ -125,31 +124,22 @@ async def generate_copy(
|
||||
grounding.append(FactInput(key="place_name", label="상호명", value=place_name))
|
||||
allowed_keys = {f.key for f in facts} | {f.key for f in grounding}
|
||||
|
||||
body = {
|
||||
"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,
|
||||
},
|
||||
}
|
||||
prompt = build_prompt(place_name, category, facts, max_faqs, unit_grounding, records, suggested_questions)
|
||||
|
||||
owns_client = client is None
|
||||
client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0))
|
||||
try:
|
||||
payload = await call(client, model, body, max_retries)
|
||||
parsed = json.loads(extract_text(payload))
|
||||
except json.JSONDecodeError as ex:
|
||||
raise GeminiInvalidOutput(f"구조화 출력 파싱 실패: {ex}") from ex
|
||||
llm_result = await llm.generate(
|
||||
client, model, prompt=prompt, response_schema=RESPONSE_SCHEMA, temperature=0.2, max_retries=max_retries,
|
||||
)
|
||||
finally:
|
||||
if owns_client:
|
||||
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()
|
||||
@ -190,10 +180,10 @@ async def generate_copy(
|
||||
result.faqs.append(GeneratedFaq(question=question, answer=answer, fact_keys=keys))
|
||||
|
||||
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"반려 {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
|
||||
|
||||
@ -215,7 +205,7 @@ _SUMMARY_PROMPT = (
|
||||
async def summarize_text(
|
||||
text: str,
|
||||
*,
|
||||
model: str = DEFAULT_TEXT_MODEL,
|
||||
model: Optional[str] = None,
|
||||
max_retries: int = 2,
|
||||
client: Optional[httpx.AsyncClient] = None,
|
||||
) -> Optional[str]:
|
||||
@ -227,8 +217,10 @@ async def summarize_text(
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
if not is_configured():
|
||||
llm = provider.active()
|
||||
if not llm.is_configured():
|
||||
return None
|
||||
model = model or llm.DEFAULT_MODEL
|
||||
|
||||
# 길이 기준을 바꾼 뒤 이전 길이의 요약을 재사용하지 않도록 프롬프트도 키에 넣는다.
|
||||
cache_key = hashlib.sha256((_SUMMARY_PROMPT + stripped).encode("utf-8")).hexdigest()
|
||||
@ -236,20 +228,13 @@ async def summarize_text(
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
body = {
|
||||
"contents": [{"role": "user", "parts": [{
|
||||
"text": _SUMMARY_PROMPT + stripped,
|
||||
}]}],
|
||||
"generationConfig": {"temperature": 0.2},
|
||||
}
|
||||
|
||||
owns_client = client is None
|
||||
client = client or httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0))
|
||||
try:
|
||||
payload = await call(client, model, body, max_retries)
|
||||
summary = extract_text(payload).strip()
|
||||
except GeminiError as ex:
|
||||
LOG.w(f"[gemini-text] 요약 실패: {ex}")
|
||||
result = await llm.generate(client, model, prompt=_SUMMARY_PROMPT + stripped, temperature=0.2, max_retries=max_retries)
|
||||
summary = result.text.strip()
|
||||
except LlmError as ex:
|
||||
LOG.w(f"[llm-text] 요약 실패: {ex}")
|
||||
return None
|
||||
finally:
|
||||
if owns_client:
|
||||
@ -279,7 +264,7 @@ async def generate_song(
|
||||
region: str,
|
||||
grounding: list[str],
|
||||
intro: str = "",
|
||||
model: str = DEFAULT_TEXT_MODEL,
|
||||
model: Optional[str] = None,
|
||||
max_retries: int = 2,
|
||||
client: Optional[httpx.AsyncClient] = None,
|
||||
) -> GeneratedSong:
|
||||
@ -291,47 +276,39 @@ async def generate_song(
|
||||
★ 재료가 하나도 없으면 부르지 않는다 — 소개문과 같은 규칙이다. 상호와 지역만으로 쓴 노래는
|
||||
어느 숙소에 붙여도 말이 되는 노래이고, 그건 이 기능이 하려던 일이 아니다.
|
||||
"""
|
||||
if not is_configured():
|
||||
raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다")
|
||||
llm = provider.active()
|
||||
if not llm.is_configured():
|
||||
raise GeminiNotConfigured("API 키가 설정되지 않았다")
|
||||
if not grounding and not (intro or "").strip():
|
||||
raise GeminiInvalidOutput("가사를 쓸 재료가 없다 — 확인된 fact 도 소개문도 없다")
|
||||
model = model or llm.DEFAULT_MODEL
|
||||
|
||||
from common.category_schema import get_schema
|
||||
from services.prompts.song import RESPONSE_SCHEMA as SONG_SCHEMA, build_prompt as build_song_prompt
|
||||
|
||||
body = {
|
||||
"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,
|
||||
},
|
||||
}
|
||||
prompt = build_song_prompt(place_name, get_schema(category).label, region, grounding, intro)
|
||||
|
||||
owns_client = client is None
|
||||
client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0))
|
||||
try:
|
||||
payload = await call(client, model, body, max_retries)
|
||||
parsed = json.loads(extract_text(payload))
|
||||
except json.JSONDecodeError as ex:
|
||||
raise GeminiInvalidOutput(f"가사 파싱 실패: {ex}") from ex
|
||||
llm_result = await llm.generate(
|
||||
client, model, prompt=prompt, response_schema=SONG_SCHEMA, temperature=0.9, max_retries=max_retries,
|
||||
)
|
||||
finally:
|
||||
if owns_client:
|
||||
await client.aclose()
|
||||
|
||||
parsed = llm_result.json or {}
|
||||
title = (parsed.get("title") or "").strip()
|
||||
lyrics = (parsed.get("lyrics") or "").strip()
|
||||
style = (parsed.get("style") or "").strip()
|
||||
if not lyrics:
|
||||
raise GeminiInvalidOutput("가사가 비어 있다")
|
||||
|
||||
usage = read_usage(payload)
|
||||
usage = llm_result.usage
|
||||
LOG.i(
|
||||
f"[gemini-text] '{place_name}' 가사 — '{title}' ({style}) · {len(lyrics)}자 · "
|
||||
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, usage)}"
|
||||
f"[llm-text] '{place_name}' 가사 — '{title}' ({style}) · {len(lyrics)}자 · "
|
||||
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")
|
||||
|
||||
@ -30,6 +30,7 @@ from services.llm.perplexity import (
|
||||
PerplexityNotConfigured,
|
||||
call,
|
||||
is_configured,
|
||||
read_usage,
|
||||
)
|
||||
from services.prompts.channel_discovery import RESPONSE_SCHEMA, SYSTEM_PROMPT, build_prompt
|
||||
|
||||
@ -126,12 +127,13 @@ async def discover_channels(
|
||||
)
|
||||
|
||||
# 내부 검색 횟수는 품질·지연 관측값이다. Sonar 과금은 토큰 + 요청 컨텍스트 요금이다.
|
||||
usage = payload.get("usage") or {}
|
||||
usage = read_usage(payload)
|
||||
reasons = result.reason_counts()
|
||||
reason_text = " ".join(f"{k}{v}" for k, v in sorted(reasons.items())) or "없음"
|
||||
LOG.i(
|
||||
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:
|
||||
LOG.w(
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
import json
|
||||
|
||||
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
|
||||
|
||||
MAX_RESULTS = 10
|
||||
@ -54,4 +54,10 @@ async def search_region_restaurants(
|
||||
except PerplexityError as ex:
|
||||
LOG.w(f"[restaurant_discovery] '{region_label}' 검색 실패: {ex}")
|
||||
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
|
||||
|
||||
@ -126,6 +126,8 @@ async def _generate_one(
|
||||
courses: list[dict] = []
|
||||
seen: set[frozenset[str]] = set()
|
||||
notes: list[str] = []
|
||||
total_in = total_out = 0
|
||||
total_cost = 0.0
|
||||
for attempt in range(1, MAX_ATTEMPTS + 1):
|
||||
try:
|
||||
payload = await perplexity.call(body, client=client)
|
||||
@ -136,6 +138,11 @@ async def _generate_one(
|
||||
notes.append(f"{attempt}차 호출 실패: {ex}")
|
||||
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(
|
||||
payload, duration, place_name, place_lat, place_lng, already_seen=seen)
|
||||
notes += dropped
|
||||
@ -151,7 +158,10 @@ async def _generate_one(
|
||||
if 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
|
||||
|
||||
|
||||
|
||||
16
solution/backend/services/llm/errors.py
Normal file
16
solution/backend/services/llm/errors.py
Normal 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):
|
||||
"""응답이 기대한 모양이 아니다."""
|
||||
@ -8,11 +8,17 @@
|
||||
여기가 책임지지 않는 것: 무엇을 물을지(services/prompts/), 답을 믿을지(services/grounding/).
|
||||
"""
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
import base64
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
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"
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
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:
|
||||
"""키가 있는지 — 어댑터 등록/스킵 판단용. 예외를 던지지 않는다."""
|
||||
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
|
||||
out = _PRICE_PER_1M_OUTPUT.get(model, 0.0) * usage.output_tokens / 1_000_000
|
||||
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))
|
||||
|
||||
127
solution/backend/services/llm/openai.py
Normal file
127
solution/backend/services/llm/openai.py
Normal 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)
|
||||
@ -9,6 +9,7 @@
|
||||
나중에 환각을 추적할 수 있게 한다.
|
||||
"""
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
@ -36,6 +37,39 @@ def is_configured() -> bool:
|
||||
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:
|
||||
"""★ LLM 이 실제로 불리는 지점. chat/completions 1회.
|
||||
|
||||
|
||||
10
solution/backend/services/llm/provider.py
Normal file
10
solution/backend/services/llm/provider.py
Normal 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
|
||||
28
solution/backend/services/llm/types.py
Normal file
28
solution/backend/services/llm/types.py
Normal 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
|
||||
@ -98,7 +98,11 @@ async def research_place(place, place_id: str) -> dict:
|
||||
return {"error": str(ex)}
|
||||
|
||||
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:
|
||||
return {"items": 0, "dropped": dropped}
|
||||
|
||||
|
||||
@ -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 = 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
|
||||
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ from config.server_configs import external_api_config
|
||||
from crud.media_crud import MediaCRUD
|
||||
from crud.place_crud import PlaceCRUD
|
||||
from services.external import gemini
|
||||
from services.llm import provider
|
||||
from common.job_errors import PermanentJobError
|
||||
|
||||
_media_crud = MediaCRUD()
|
||||
@ -36,7 +37,7 @@ async def run_vision(job: dict) -> dict:
|
||||
force = bool(payload.get("force"))
|
||||
|
||||
if not gemini.is_configured():
|
||||
raise VisionAborted("GEMINI_API_KEY 미설정 — 사진 분석을 할 수 없다")
|
||||
raise VisionAborted("API 키 미설정 — 사진 분석을 할 수 없다")
|
||||
|
||||
err, place = await DB_SESSION_MNG.execute_lambda(
|
||||
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}
|
||||
|
||||
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:
|
||||
results = await gemini.analyze_images(
|
||||
images,
|
||||
category=PlaceCategory(place.category),
|
||||
unit_names=unit_names,
|
||||
model=external_api_config.gemini_vision_model,
|
||||
model=model,
|
||||
confidence_threshold=threshold,
|
||||
)
|
||||
except gemini.GeminiNotConfigured as ex:
|
||||
|
||||
@ -11,8 +11,6 @@ import {Section} from '@site/lib/ui';
|
||||
* 이용 정보 표 전체.
|
||||
*
|
||||
* 예약 전 확인은 이 섹션 한 곳에서 확인된 항목 전체를 낸다.
|
||||
* ★ "확인 안 된 항목이 N개 있다"를 숨기지 않는다 — 없는 척하면
|
||||
* 손님이 다른 데서 틀린 값을 찾아 온다. 있다고 말하고 문의로 보낸다.
|
||||
*
|
||||
* ★ 이용 규정을 여기로 들여왔다 (2026-09-03)
|
||||
* 따로 '이용 규정' 섹션이 있었는데, 그 줄(체크인·체크아웃·취소규정·취사·반려동물·흡연·
|
||||
@ -63,10 +61,6 @@ export function EssentialInfoSection() {
|
||||
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');
|
||||
};
|
||||
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;
|
||||
|
||||
/**
|
||||
@ -97,14 +91,7 @@ export function EssentialInfoSection() {
|
||||
title="이용안내 및 예약"
|
||||
lead="방문 전 확인이 필요한 운영 규정과 시설 안내입니다."
|
||||
footnote={
|
||||
<span className="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<span>
|
||||
{hiddenCount > 0
|
||||
? `확인 중인 항목 ${hiddenCount}개는 표시하지 않았습니다. 필요하시면 전화로 문의해 주세요.`
|
||||
: '등록 정보와 수집한 안내를 기준으로 표시합니다.'}
|
||||
</span>
|
||||
<span className="font-medium">{formatKoreanDate(payload.site.updatedAt)} 기준</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="space-y-8">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user