o2o-site-AEO/solution/backend/services/external/gemini.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- 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>
2026-09-28 16:05:19 +09:00

231 lines
8.1 KiB
Python

"""사진 라벨·접근성 alt 생성 — 겹들을 엮어 결과를 만드는 자리."""
from dataclasses import dataclass, field
from typing import Optional
import httpx
from common.enums import PlaceCategory
from common.logger import LOG
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"),
(b"\xff\xd8\xff", "image/jpeg"),
(b"GIF87a", "image/gif"),
(b"GIF89a", "image/gif"),
)
@dataclass
class ImageInput:
"""분석할 사진 1장."""
origin_url: str
data: Optional[bytes] = None # 이미 받아둔 바이트.
fetch_url: Optional[str] = None # 내려받을 주소.
mime_type: Optional[str] = None # 없으면 시그니처로 판별
unit_name_hint: Optional[str] = None # "A동 스탠다드" 같은 힌트가 있으면 라벨에 반영
@dataclass
class VisionResult:
"""사진 1장의 분석 결과."""
origin_url: str
label: Optional[str] = None
alt_text: Optional[str] = None
confidence: float = 0.0
needs_review: bool = True # 기본이 '사람 확인 필요'다.
ok: bool = False
error: Optional[str] = None
@dataclass
class _Usage:
input_tokens: int = 0
output_tokens: int = 0
batches: int = 0
failed_batches: int = 0
by_model: dict = field(default_factory=dict)
def _sniff_mime(data: bytes) -> str:
for magic, mime in _MAGIC:
if data.startswith(magic):
return mime
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp"
return "image/jpeg" # 판별 실패 시 가장 흔한 형식으로 시도한다
async def _load_bytes(client: httpx.AsyncClient, image: ImageInput) -> bytes:
"""이미지 바이트 확보."""
if image.data:
return image.data
url = image.fetch_url or image.origin_url
resp = await client.get(url, follow_redirects=True)
if resp.status_code != 200:
raise GeminiError(f"이미지 내려받기 실패 status={resp.status_code}")
return resp.content
async def _run_batch(
client: httpx.AsyncClient,
batch: list[ImageInput],
*,
model: str,
category: Optional[PlaceCategory],
unit_names: Optional[list[str]],
confidence_threshold: float,
max_retries: int,
usage: _Usage,
) -> dict[str, VisionResult]:
"""배치 1개 처리."""
out: dict[str, VisionResult] = {}
ref_map: dict[str, ImageInput] = {}
images_payload: list[ImagePart] = []
for i, image in enumerate(batch):
ref = f"img-{i}"
try:
data = await _load_bytes(client, image)
except Exception as ex:
# 이 사진만 실패.
out[image.origin_url] = VisionResult(
origin_url=image.origin_url, ok=False, needs_review=True,
error=f"이미지 로드 실패: {type(ex).__name__}: {ex}",
)
continue
ref_map[ref] = image
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
prompt = build_prompt(category, unit_names, sorted(ref_map))
llm_provider = provider.active()
usage.batches += 1
try:
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"[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,
error=f"{type(ex).__name__}: {str(ex)[:200]}",
)
return out
batch_usage = llm_result.usage
usage.input_tokens += batch_usage.input_tokens
usage.output_tokens += batch_usage.output_tokens
# 순서가 아니라 ref 로 매칭한다.
seen: set[str] = set()
for item in parsed.get("items") or []:
ref = str(item.get("ref", "")).strip()
image = ref_map.get(ref)
if image is None:
continue
seen.add(ref)
try:
confidence = float(item.get("confidence") or 0.0)
except (TypeError, ValueError):
confidence = 0.0
confidence = max(0.0, min(1.0, confidence))
label = (item.get("label") or "").strip() or None
alt = (item.get("alt_text") or "").strip() or None
out[image.origin_url] = VisionResult(
origin_url=image.origin_url,
label=label,
alt_text=alt,
confidence=confidence,
# 신뢰도 미달이거나 라벨/alt 가 비면 자동 반영하지 않는다.
needs_review=confidence < confidence_threshold or not label or not alt,
ok=True,
)
# 응답에서 빠진 사진 — 조용히 사라지지 않게 자리를 채운다.
for ref, image in ref_map.items():
if ref not in seen:
out[image.origin_url] = VisionResult(
origin_url=image.origin_url, ok=False, needs_review=True,
error="응답에 해당 ref 가 없다",
)
return out
async def analyze_images(
images: list[ImageInput],
*,
category: Optional[PlaceCategory] = None,
unit_names: Optional[list[str]] = None,
model: Optional[str] = None,
batch_size: int = 10,
confidence_threshold: float = 0.7,
max_retries: int = 2,
client: Optional[httpx.AsyncClient] = None,
) -> list[VisionResult]:
"""사진들을 분류하고 alt 를 만든다."""
llm_provider = provider.active()
if not llm_provider.is_configured():
raise GeminiNotConfigured("API 키가 설정되지 않았다")
model = model or llm_provider.DEFAULT_MODEL
if not images:
return []
owns_client = client is None
# 사진 여러 장을 배치로 보내므로 타임아웃을 넉넉히 잡는다.
client = client or httpx.AsyncClient(timeout=httpx.Timeout(180.0, connect=10.0))
usage = _Usage()
merged: dict[str, VisionResult] = {}
try:
for start in range(0, len(images), max(1, batch_size)):
batch = images[start:start + max(1, batch_size)]
merged.update(await _run_batch(
client, batch,
model=model, category=category, unit_names=unit_names,
confidence_threshold=confidence_threshold, max_retries=max_retries, usage=usage,
))
finally:
if owns_client:
await client.aclose()
results = [
merged.get(img.origin_url)
or VisionResult(origin_url=img.origin_url, ok=False, needs_review=True, error="결과 누락")
for img in 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"[vision] 사진분석 {len(results)}장 (성공 {ok} · 확인필요 {review}) · "
f"배치 {usage.batches}(실패 {usage.failed_batches}) · model={model} · "
f"tokens in={usage.input_tokens} out={usage.output_tokens} · "
f"약 ${llm_provider.price(model, Usage(usage.input_tokens, usage.output_tokens))}"
)
return results