여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제 - 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다 - eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다 파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이). site·frontend·admin tsc, site vitest 105 passed Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
166 lines
6.0 KiB
Python
166 lines
6.0 KiB
Python
"""사이트 1건 생성 원가 미터 — **$1 예산을 코드로 강제한다.**"""
|
|
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
|
|
from common.logger import LOG
|
|
|
|
# ── 예산 ────────────────────────────────────────────────────────
|
|
USD_KRW = 1400.0 # 환산 환율.
|
|
SITE_BUDGET_KRW = 1400.0 # 사이트 1건당 상한 = $1
|
|
# 예산의 몇 %까지 차면 경고를 남길지.
|
|
WARN_RATIO = 0.7
|
|
|
|
|
|
class Provider(Enum):
|
|
PERPLEXITY = "perplexity"
|
|
KAKAO = "kakao"
|
|
GEMINI = "gemini"
|
|
TOUR_API = "tour_api"
|
|
OPEN_METEO = "open_meteo"
|
|
THREADS = "threads"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Rate:
|
|
"""공급자별 단가(원 기준)."""
|
|
|
|
per_call_krw: float = 0.0 # 호출 1건당 고정비
|
|
per_search_krw: float = 0.0 # 검색 1회당 (Perplexity 는 토큰과 별도 과금)
|
|
per_1k_token_krw: float = 0.0 # 토큰 1,000개당
|
|
per_image_krw: float = 0.0 # 이미지 1장당
|
|
confirmed: bool = False
|
|
source: str = ""
|
|
|
|
|
|
# ── 단가표 ──────────────────────────────────────────────────────
|
|
RATES: dict[Provider, Rate] = {
|
|
Provider.THREADS: Rate(confirmed=False, source="공개 과금 미확인 — API_USAGE 5절; 계정 계약비는 별도"),
|
|
# 레포에 확정값이 있다(.env.example): 키워드/카테고리 검색 2원, 좌표 변환 0.5원.
|
|
Provider.KAKAO: Rate(
|
|
per_call_krw=2.0,
|
|
confirmed=True,
|
|
source=".env.example — 키워드/카테고리 검색 2원 (무료 쿼터 초과분)",
|
|
),
|
|
# Sonar 기본 search_context_size=low: 요청 $5/1K + 입력/출력 각각 $1/1M.
|
|
Provider.PERPLEXITY: Rate(
|
|
per_call_krw=7.0,
|
|
per_1k_token_krw=1.4,
|
|
confirmed=True,
|
|
source="https://docs.perplexity.ai/docs/getting-started/pricing — Sonar low context",
|
|
),
|
|
# 미확인 — Google AI Studio 단가표 확인 후 교체할 것.
|
|
Provider.GEMINI: Rate(
|
|
per_image_krw=1.0,
|
|
per_1k_token_krw=0.5,
|
|
confirmed=False,
|
|
source="추정치",
|
|
),
|
|
# 공공데이터·무료.
|
|
Provider.TOUR_API: Rate(confirmed=True, source="공공데이터포털 무료(일 트래픽 제한)"),
|
|
Provider.OPEN_METEO: Rate(confirmed=True, source="무료, 키 불필요"),
|
|
}
|
|
|
|
# 카카오 좌표 변환은 키워드 검색보다 4배 싸다 — 같은 공급자지만 단가가 다르다.
|
|
KAKAO_COORD_KRW = 0.5
|
|
|
|
|
|
class BudgetExceeded(Exception):
|
|
|
|
def __init__(self, place_id: int | None, spent_krw: float, would_add_krw: float):
|
|
self.place_id = place_id
|
|
self.spent_krw = spent_krw
|
|
self.would_add_krw = would_add_krw
|
|
super().__init__(
|
|
f"사이트 원가 예산 초과 (place_id={place_id}): "
|
|
f"이미 {spent_krw:.1f}원 + 이번 {would_add_krw:.1f}원 > 상한 {SITE_BUDGET_KRW:.0f}원"
|
|
)
|
|
|
|
|
|
class UnconfirmedRate(Exception):
|
|
"""추정 단가로 실배치를 돌리려 했다."""
|
|
|
|
|
|
def assert_rates_confirmed(*providers: Provider) -> None:
|
|
"""실배치 직전에 호출한다."""
|
|
bad = [p for p in (providers or tuple(RATES)) if not RATES[p].confirmed]
|
|
if bad:
|
|
names = ", ".join(p.value for p in bad)
|
|
raise UnconfirmedRate(
|
|
f"단가 미확인: {names} — 공식 단가표로 RATES 를 교체한 뒤에 배치를 돌려라"
|
|
)
|
|
|
|
|
|
def estimate_krw(
|
|
provider: Provider,
|
|
*,
|
|
calls: int = 0,
|
|
searches: int = 0,
|
|
tokens: int = 0,
|
|
images: int = 0,
|
|
kakao_coord_calls: int = 0,
|
|
) -> float:
|
|
"""이번 호출의 원가(원)를 계산한다."""
|
|
rate = RATES[provider]
|
|
krw = (
|
|
rate.per_call_krw * calls
|
|
+ rate.per_search_krw * searches
|
|
+ rate.per_1k_token_krw * (tokens / 1000.0)
|
|
+ rate.per_image_krw * images
|
|
)
|
|
if provider is Provider.KAKAO:
|
|
krw += KAKAO_COORD_KRW * kakao_coord_calls
|
|
return krw
|
|
|
|
|
|
@dataclass
|
|
class CostMeter:
|
|
"""사이트 1건(업소 1곳)의 원가 누적기."""
|
|
|
|
place_id: int | None = None
|
|
budget_krw: float = SITE_BUDGET_KRW
|
|
spent_krw: float = 0.0
|
|
by_provider: dict[str, float] = field(default_factory=dict)
|
|
calls: int = 0
|
|
_warned: bool = False
|
|
|
|
@property
|
|
def remaining_krw(self) -> float:
|
|
return max(0.0, self.budget_krw - self.spent_krw)
|
|
|
|
@property
|
|
def spent_usd(self) -> float:
|
|
return self.spent_krw / USD_KRW
|
|
|
|
def guard(self, provider: Provider, **units) -> float:
|
|
"""호출 **전** 에 예산을 확인한다."""
|
|
krw = estimate_krw(provider, **units)
|
|
if self.spent_krw + krw > self.budget_krw:
|
|
raise BudgetExceeded(self.place_id, self.spent_krw, krw)
|
|
return krw
|
|
|
|
def charge(self, provider: Provider, **units) -> float:
|
|
"""이미 나간 호출은 되돌릴 수 없다 — 예외의 목적은 **다음 호출을 막는 것**이다."""
|
|
krw = estimate_krw(provider, **units)
|
|
self.spent_krw += krw
|
|
self.calls += 1
|
|
self.by_provider[provider.value] = self.by_provider.get(provider.value, 0.0) + krw
|
|
|
|
if not self._warned and self.spent_krw >= self.budget_krw * WARN_RATIO:
|
|
self._warned = True
|
|
LOG.w(
|
|
f"[cost] place_id={self.place_id} 예산 {WARN_RATIO:.0%} 소진 — "
|
|
f"{self.spent_krw:.1f}/{self.budget_krw:.0f}원"
|
|
)
|
|
if self.spent_krw > self.budget_krw:
|
|
LOG.w(f"[cost] place_id={self.place_id} 예산 초과 — {self.summary()}")
|
|
raise BudgetExceeded(self.place_id, self.spent_krw - krw, krw)
|
|
return krw
|
|
|
|
def summary(self) -> str:
|
|
parts = " ".join(f"{k}={v:.1f}원" for k, v in sorted(self.by_provider.items()))
|
|
return (
|
|
f"place_id={self.place_id} 합계={self.spent_krw:.1f}원(${self.spent_usd:.3f}) "
|
|
f"호출={self.calls}건 상한={self.budget_krw:.0f}원 [{parts}]"
|
|
)
|