o2o-site-AEO/solution/backend/tests/test_gemini.py
Mina Choi 9d25ed613e 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다
최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.

  backend/ frontend/{admin,site,shared}  →  solution/{backend,front,site,shared} + admin/

## 왜

내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.

그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
  local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
  나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
  (앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).

## admin 에 백엔드를 두지 않았다

내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.

## admin 의 `@` 는 solution/front/src 를 가리킨다

내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.

admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.

## 그 밖

- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
  127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
  VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
  compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
  디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
  (conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
  APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.

검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 15:12:09 +09:00

454 lines
18 KiB
Python

"""Gemini Vision 클라이언트 — 사진 분류·alt 생성의 계약.
실제 API 를 절대 호출하지 않는다(httpx.MockTransport). 여기서 고정하는 것:
1. ★ 반환 길이는 항상 입력과 같다 — 실패해도 자리를 지킨다
2. ★ 매칭은 순서가 아니라 ref 로 한다 — 순서로 하면 엉뚱한 사진에 남의 alt 가 붙는다
3. ★ 신뢰도가 낮으면 needs_review=True — 자동 반영하지 않고 사람 확인 큐로
4. 배치 하나가 죽어도 나머지는 산다
5. 홍보성 형용사 금지가 프롬프트에 실제로 들어간다
"""
import base64
import json
import struct
import zlib
import httpx
import pytest
from common.enums import PlaceCategory
from services.external import gemini
from services.llm import gemini as llm
from services.external.gemini import (
GeminiError,
GeminiNotConfigured,
ImageInput,
analyze_images,
)
# ---- 도구 -----------------------------------------------------------------
def _png(rgb=(10, 20, 30), size=8) -> bytes:
"""테스트용 최소 PNG. 시그니처 판별(_sniff_mime)까지 같이 확인된다."""
def chunk(tag, data):
body = tag + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
raw = b"".join(b"\x00" + bytes(rgb) * size for _ in range(size))
return (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw))
+ chunk(b"IEND", b""))
def _inputs(n: int) -> list[ImageInput]:
return [ImageInput(origin_url=f"https://ota.test/p{i}.png", data=_png((i * 20 % 255, 40, 60))) for i in range(n)]
def _reply(items: list[dict], *, prompt_tokens=1000, out_tokens=50) -> dict:
"""generateContent 성공 응답 흉내. 실호출에서 확인한 구조 그대로 —
parts 에 text 와 thoughtSignature 가 같이 실린다."""
return {
"candidates": [{
"content": {"parts": [{"text": json.dumps({"items": items}, ensure_ascii=False),
"thoughtSignature": "xxx"}]},
"finishReason": "STOP",
}],
"usageMetadata": {"promptTokenCount": prompt_tokens, "candidatesTokenCount": out_tokens},
}
def _item(ref, label="침실", alt="침대와 협탁이 놓인 방", conf=0.95):
return {"ref": ref, "label": label, "alt_text": alt, "confidence": conf}
def _client(handler) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(handler), timeout=5.0)
@pytest.fixture(autouse=True)
def _api_key(monkeypatch):
"""테스트 환경엔 키가 없다(의도된 것) — 클라이언트 경로를 타려면 넣어줘야 한다."""
monkeypatch.setattr(llm.external_api_config, "gemini_api_key", "test-key")
# ---- 기본 경로 -------------------------------------------------------------
async def test_parses_label_alt_and_confidence():
"""검증: 정상 응답 1장.
기대결과: label·alt_text·confidence 가 파싱되고 ok=True, 신뢰도가 높아 확인 불필요."""
def handler(request):
return httpx.Response(200, json=_reply([_item("img-0", "침실", "침대와 협탁이 놓인 방", 0.93)]))
async with _client(handler) as c:
res = await analyze_images(_inputs(1), category=PlaceCategory.LODGING, client=c)
assert len(res) == 1
assert res[0].ok is True
assert res[0].label == "침실"
assert res[0].alt_text == "침대와 협탁이 놓인 방"
assert res[0].confidence == pytest.approx(0.93)
assert res[0].needs_review is False
async def test_result_length_always_matches_input():
"""검증: 입력 5장인데 응답에 3장만 온다.
기대결과: 길이 5 유지 · 빠진 2장은 ok=False, needs_review=True — ★ 조용히 사라지지 않는다."""
def handler(request):
return httpx.Response(200, json=_reply([_item("img-0"), _item("img-1"), _item("img-2")]))
images = _inputs(5)
async with _client(handler) as c:
res = await analyze_images(images, client=c)
assert len(res) == 5
assert [r.origin_url for r in res] == [i.origin_url for i in images]
assert all(r.ok for r in res[:3])
for r in res[3:]:
assert r.ok is False and r.needs_review is True
assert "ref" in (r.error or "")
async def test_matches_by_ref_not_by_order():
"""검증: 모델이 순서를 뒤집어 돌려준다(img-2, img-0, img-1).
기대결과: ★ ref 로 정확히 매칭된다 — 순서로 매칭하면 엉뚱한 사진에 남의 alt 가 붙는다."""
def handler(request):
return httpx.Response(200, json=_reply([
_item("img-2", "주방", "싱크대와 조리대"),
_item("img-0", "외관", "건물 정면"),
_item("img-1", "욕실", "세면대와 샤워부스"),
]))
images = _inputs(3)
async with _client(handler) as c:
res = await analyze_images(images, client=c)
by_url = {r.origin_url: r for r in res}
assert by_url[images[0].origin_url].label == "외관"
assert by_url[images[1].origin_url].label == "욕실"
assert by_url[images[2].origin_url].label == "주방"
async def test_unknown_ref_in_response_is_discarded():
"""검증: 모델이 존재하지 않는 ref(img-99)를 지어낸다.
기대결과: 버려지고, 실제 사진은 '응답 누락'으로 표시된다."""
def handler(request):
return httpx.Response(200, json=_reply([_item("img-99", "침실")]))
async with _client(handler) as c:
res = await analyze_images(_inputs(1), client=c)
assert len(res) == 1
assert res[0].ok is False and res[0].needs_review is True
# ---- 신뢰도 게이트 ---------------------------------------------------------
async def test_low_confidence_goes_to_review_queue():
"""검증: 신뢰도 0.4 로 돌아온 사진(임계값 0.7).
기대결과: ok=True 지만 needs_review=True — ★ 자동 반영하지 않고 사람이 본다."""
def handler(request):
return httpx.Response(200, json=_reply([_item("img-0", "침실", "흐릿한 실내", 0.4)]))
async with _client(handler) as c:
res = await analyze_images(_inputs(1), confidence_threshold=0.7, client=c)
assert res[0].ok is True
assert res[0].needs_review is True
async def test_threshold_is_configurable():
"""검증: 같은 0.4 응답에 임계값을 0.3 으로 낮춘다.
기대결과: 확인 불필요로 내려간다 — 임계값이 실제로 파라미터로 동작한다."""
def handler(request):
return httpx.Response(200, json=_reply([_item("img-0", "침실", "실내", 0.4)]))
async with _client(handler) as c:
res = await analyze_images(_inputs(1), confidence_threshold=0.3, client=c)
assert res[0].needs_review is False
async def test_empty_label_forces_review():
"""검증: 신뢰도는 높은데 label 이 빈 문자열이다.
기대결과: needs_review=True — 쓸 수 없는 결과를 자동 반영하지 않는다."""
def handler(request):
return httpx.Response(200, json=_reply([_item("img-0", "", "설명", 0.99)]))
async with _client(handler) as c:
res = await analyze_images(_inputs(1), client=c)
assert res[0].label is None
assert res[0].needs_review is True
# ---- 배치 -----------------------------------------------------------------
async def test_splits_into_batches():
"""검증: 12장을 batch_size=5 로 보낸다.
기대결과: 3번 호출된다(5+5+2) — 20~50장을 한 번에 밀어넣지 않는다."""
calls = []
def handler(request):
body = json.loads(request.content)
refs = [p["text"].split("]")[0][1:] for p in body["contents"][0]["parts"]
if "text" in p and p["text"].startswith("[img-")]
calls.append(len(refs))
return httpx.Response(200, json=_reply([_item(r) for r in refs]))
async with _client(handler) as c:
res = await analyze_images(_inputs(12), batch_size=5, client=c)
assert calls == [5, 5, 2]
assert len(res) == 12
assert all(r.ok for r in res)
async def test_one_failed_batch_does_not_kill_the_rest():
"""검증: 3배치 중 두 번째만 500 을 반환한다.
기대결과: ★ 나머지 배치는 살아남고, 실패 배치의 사진만 ok=False 로 표시된다."""
state = {"n": 0}
def handler(request):
state["n"] += 1
body = json.loads(request.content)
refs = [p["text"][1:-1] for p in body["contents"][0]["parts"]
if "text" in p and p["text"].startswith("[img-")]
if state["n"] == 2:
return httpx.Response(500, text="boom")
return httpx.Response(200, json=_reply([_item(r) for r in refs]))
async with _client(handler) as c:
res = await analyze_images(_inputs(6), batch_size=2, max_retries=0, client=c)
assert len(res) == 6
assert [r.ok for r in res] == [True, True, False, False, True, True]
assert all(r.needs_review for r in res[2:4])
# ---- 재시도 ---------------------------------------------------------------
async def test_retries_5xx_then_succeeds():
"""검증: 첫 호출 503, 두 번째 200.
기대결과: 재시도로 성공한다 — 일시적 장애로 사진을 버리지 않는다."""
state = {"n": 0}
def handler(request):
state["n"] += 1
if state["n"] == 1:
return httpx.Response(503, text="unavailable")
return httpx.Response(200, json=_reply([_item("img-0")]))
async with _client(handler) as c:
res = await analyze_images(_inputs(1), max_retries=2, client=c)
assert state["n"] == 2
assert res[0].ok is True
async def test_does_not_retry_4xx():
"""검증: 400(잘못된 요청)을 반환한다.
기대결과: 재시도하지 않는다 — 같은 요청을 다시 보내도 결과가 같고 요금만 나간다."""
state = {"n": 0}
def handler(request):
state["n"] += 1
return httpx.Response(400, text="bad request")
async with _client(handler) as c:
res = await analyze_images(_inputs(1), max_retries=3, client=c)
assert state["n"] == 1
assert res[0].ok is False
async def test_timeout_is_retried_then_reported():
"""검증: 매번 타임아웃이 난다.
기대결과: max_retries 만큼 시도한 뒤 해당 사진을 ok=False 로 남긴다(예외를 밖으로 안 던진다)."""
state = {"n": 0}
def handler(request):
state["n"] += 1
raise httpx.ReadTimeout("timed out", request=request)
async with _client(handler) as c:
res = await analyze_images(_inputs(1), max_retries=1, client=c)
assert state["n"] == 2
assert res[0].ok is False and res[0].needs_review is True
# ---- 오류 처리 -------------------------------------------------------------
async def test_broken_json_fails_only_that_batch():
"""검증: 구조화 출력이 깨진 JSON 으로 온다.
기대결과: 그 배치만 실패 처리된다 — 파싱 실패가 전체를 죽이지 않는다."""
def handler(request):
return httpx.Response(200, json={
"candidates": [{"content": {"parts": [{"text": "{items: [oops"}]}, "finishReason": "STOP"}]
})
async with _client(handler) as c:
res = await analyze_images(_inputs(2), max_retries=0, client=c)
assert len(res) == 2
assert all(r.ok is False and r.needs_review for r in res)
async def test_safety_blocked_response_is_handled():
"""검증: candidates 가 비어 오는 경우(안전 필터 차단).
기대결과: 예외가 새지 않고 ok=False 로 남는다."""
def handler(request):
return httpx.Response(200, json={"candidates": []})
async with _client(handler) as c:
res = await analyze_images(_inputs(1), max_retries=0, client=c)
assert res[0].ok is False
async def test_missing_key_raises_not_configured(monkeypatch):
"""검증: GEMINI_API_KEY 가 비어 있다.
기대결과: GeminiNotConfigured — ★ 서버 부팅은 막지 않고 이 어댑터만 비활성이다."""
monkeypatch.setattr(llm.external_api_config, "gemini_api_key", "")
assert llm.is_configured() is False
with pytest.raises(GeminiNotConfigured):
await analyze_images(_inputs(1))
async def test_auth_error_stops_everything():
"""검증: 401 이 돌아온다.
기대결과: GeminiNotConfigured 로 전체 중단 — 나머지 배치를 태워봐야 똑같이 실패한다."""
def handler(request):
return httpx.Response(401, text="unauthorized")
async with _client(handler) as c:
with pytest.raises(GeminiNotConfigured):
await analyze_images(_inputs(4), batch_size=2, client=c)
async def test_image_download_failure_is_isolated():
"""검증: 바이트 없이 URL 만 준 사진의 내려받기가 실패한다.
기대결과: 그 사진만 ok=False, 같은 배치의 다른 사진은 정상 처리된다."""
def handler(request):
if request.method == "GET":
return httpx.Response(404)
body = json.loads(request.content)
refs = [p["text"][1:-1] for p in body["contents"][0]["parts"]
if "text" in p and p["text"].startswith("[img-")]
return httpx.Response(200, json=_reply([_item(r) for r in refs]))
images = [ImageInput(origin_url="https://ota.test/gone.png"), _inputs(1)[0]]
async with _client(handler) as c:
res = await analyze_images(images, client=c)
assert len(res) == 2
assert res[0].ok is False and "로드 실패" in res[0].error
assert res[1].ok is True
async def test_empty_input_returns_empty():
"""검증: 빈 목록을 넘긴다.
기대결과: 빈 목록. 호출도 하지 않는다(요금 0)."""
assert await analyze_images([]) == []
# ---- 프롬프트 계약 ---------------------------------------------------------
async def test_prompt_forbids_promotional_adjectives():
"""검증: 실제로 보내는 요청 본문의 지시문.
기대결과: ★ 홍보성 형용사 금지와 '지어내지 마라' 가 들어 있다 — LLM 은 사실을 만들지 않는다."""
captured = {}
def handler(request):
captured["body"] = json.loads(request.content)
return httpx.Response(200, json=_reply([_item("img-0")]))
async with _client(handler) as c:
await analyze_images(_inputs(1), category=PlaceCategory.LODGING, client=c)
prompt = captured["body"]["contents"][0]["parts"][0]["text"]
assert "아름다운" in prompt and "홍보성" in prompt
assert "지어내지 마라" in prompt
assert "보이는 것만" in prompt
async def test_prompt_uses_category_vocabulary():
"""검증: 업종별 라벨 어휘.
기대결과: 숙박엔 '침실'이, 카페엔 '디저트'가 들어간다 — 라벨이 업종마다 달라야 화면에서 묶인다."""
captured = {}
def handler(request):
captured.setdefault("prompts", []).append(
json.loads(request.content)["contents"][0]["parts"][0]["text"]
)
return httpx.Response(200, json=_reply([_item("img-0")]))
async with _client(handler) as c:
await analyze_images(_inputs(1), category=PlaceCategory.LODGING, client=c)
await analyze_images(_inputs(1), category=PlaceCategory.CAFE, client=c)
assert "침실" in captured["prompts"][0]
assert "디저트" in captured["prompts"][1]
async def test_unit_names_are_passed_as_hint():
"""검증: 객실 이름 후보를 넘긴다.
기대결과: 프롬프트에 실린다 — 라벨을 units 와 맞춰야 나중에 사진이 객실에 붙는다."""
captured = {}
def handler(request):
captured["prompt"] = json.loads(request.content)["contents"][0]["parts"][0]["text"]
return httpx.Response(200, json=_reply([_item("img-0")]))
async with _client(handler) as c:
await analyze_images(_inputs(1), unit_names=["A동 스탠다드", "B동 복층"], client=c)
assert "A동 스탠다드" in captured["prompt"]
async def test_structured_output_schema_is_sent():
"""검증: 요청의 generationConfig.
기대결과: responseMimeType=application/json + responseSchema 가 실린다 — 파싱 실패를 줄이는 장치."""
captured = {}
def handler(request):
captured["body"] = json.loads(request.content)
return httpx.Response(200, json=_reply([_item("img-0")]))
async with _client(handler) as c:
await analyze_images(_inputs(1), client=c)
cfg = captured["body"]["generationConfig"]
assert cfg["responseMimeType"] == "application/json"
assert cfg["responseSchema"]["properties"]["items"]["items"]["required"] == [
"ref", "label", "alt_text", "confidence"
]
assert cfg["temperature"] == 0
async def test_png_mime_is_sniffed_not_guessed():
"""검증: PNG 바이트를 mime_type 없이 넘긴다.
기대결과: image/png 로 판별된다 — 수집한 URL 의 확장자는 자주 거짓말한다."""
captured = {}
def handler(request):
captured["body"] = json.loads(request.content)
return httpx.Response(200, json=_reply([_item("img-0")]))
async with _client(handler) as c:
await analyze_images(_inputs(1), client=c)
inline = [p["inline_data"] for p in captured["body"]["contents"][0]["parts"] if "inline_data" in p]
assert inline[0]["mime_type"] == "image/png"
assert base64.b64decode(inline[0]["data"]).startswith(b"\x89PNG")
async def test_confidence_is_clamped():
"""검증: 모델이 범위 밖 신뢰도(1.7, -0.2)를 준다.
기대결과: 0.0~1.0 으로 잘린다 — 오염된 값이 아래 로직으로 흘러가지 않는다."""
def handler(request):
return httpx.Response(200, json=_reply([
_item("img-0", conf=1.7), _item("img-1", conf=-0.2),
]))
async with _client(handler) as c:
res = await analyze_images(_inputs(2), client=c)
assert res[0].confidence == 1.0
assert res[1].confidence == 0.0
assert res[1].needs_review is True