o2o-site-AEO/solution/backend/tests/test_agent_runtime.py
hbyang 5b54daac10 [chore] solution/backend: 에이전트 대화창 다시 염 — AGENT_CHAT_ENABLED 기본 1
카카오톡 채널의 통신사 인증이 끝나 어제 걸어 둔 보류를 푼다. 코드는 어제도 오늘도
그대로고 값만 바꿨다 — 닫고 여는 일이 커밋을 되짚는 일이 되면 안 된다는 어제
판단이 하루 만에 값을 쳤다.

- config/agent_config: 기본값 0 → 1
- ★ 켜도 LLM 키가 없으면 안 열린다(runtime.is_configured 가 스위치와 키를 둘 다
  본다). 키 없는 환경에서 켜 둔 채 잊어도 "눌러도 안 되는 입구" 가 안 생긴다
- 스위치 테스트를 새 기본값에 맞춰 갱신 — 키가 없을 때도 안 열리는 것을 함께 검사

카카오 연결 카드는 아직 감춰져 있다(KAKAO_CHANNEL_PUBLIC_ID 미설정). 채우면 코드는
발급되지만 소비할 웹훅(4단계)이 없어 연결이 완성되지 않는다.

test_agent_runtime·test_kakao_link 34 passed. npm run lint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:17:48 +09:00

251 lines
12 KiB
Python

"""사장님 에이전트 런타임.
여기서 지키는 것 셋 — 나머지 검사는 전부 이 셋을 지탱한다.
1. 도구는 서비스 계층을 통과한다(게이트가 살아 있다)
2. 등급은 레지스트리가 정한다 — 모델이 확인 절차를 건너뛸 수 없다
3. 모호하면 실행하지 않고 되묻는다
"""
import uuid
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from sqlalchemy import text
from common.enums import PlaceCategory
from services.agent import runtime, tools
from services.agent.tools import ToolGrade
from services.llm.errors import LlmError
@pytest.fixture
def choose(monkeypatch):
"""LLM 을 대신한다 — 테스트는 절대 실제 모델을 부르지 않는다."""
def _set(payload):
monkeypatch.setattr(runtime, "_choose", AsyncMock(return_value=payload))
monkeypatch.setenv("AGENT_CHAT_ENABLED", "1")
monkeypatch.setattr(runtime, "is_configured", lambda: True)
return _set
async def seed(client, auth_headers, name="대화숙소"):
h = await auth_headers(f"agent-{uuid.uuid4().hex[:8]}")
res = await client.post("/v1/place", headers=h, json={"name": name, "category": 1})
return h, res.json()["place"]["place_id"]
async def user_of(client, headers, place_id):
"""라우터를 거치지 않고 런타임을 직접 부르기 위한 UserInfo."""
me = (await client.get("/v1/place", headers=headers)).json()
del me
from router.v1.validator.dependencies import decode_access_token
token = headers["Authorization"].split(" ", 1)[1]
return decode_access_token(token)
# ── 1. 게이트가 살아 있다 ────────────────────────────────────────────────
def test_모든_도구는_서비스_계층을_통과한다():
"""★ 도구가 crud 를 직접 부르면 스키마 검증·출처·정정본 보호가 조용히 사라진다.
소스에 `_crud.` 직접 호출이 없는지 본다 — 주석이 아니라 코드로 못 박는 자리다."""
import inspect
source = inspect.getsource(tools)
body = source[source.index("# ── 읽기"):source.index("class ToolRejected")]
assert "fact_crud." not in body
assert "place_crud." not in body
assert "DB_SESSION_MNG" not in body
def test_없는_항목은_스키마가_막는다(db_engine):
schema_keys = {f["key"] for f in tools.fields_of(SimpleNamespace(category=PlaceCategory.LODGING.value))}
assert "check_in_time" in schema_keys
assert "고르곤졸라피자" not in schema_keys
# ── 2. 등급은 레지스트리가 정한다 ────────────────────────────────────────
def test_등급은_프롬프트에_실리지_않는다():
"""모델이 등급을 알면 그 값을 골라 보려 한다. 알 필요도, 정할 이유도 없다."""
described = tools.describe()
assert described
for row in described:
assert "grade" not in row and "등급" not in row
async def test_발행은_묻기_전에_실행되지_않는다(client, auth_headers, choose, db_engine):
h, pid = await seed(client, auth_headers)
choose({"tool": "publish", "args": {}, "message": ""})
started = AsyncMock()
tools.REGISTRY["publish"].run, original = started, tools.REGISTRY["publish"].run
try:
res = await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "발행해줘"})
finally:
tools.REGISTRY["publish"].run = original
body = res.json()
assert body["needs_confirm"] is True
assert body["tool"] == "publish"
# ★ 실행되지 않았다. 확인 문구만 돌아왔다.
started.assert_not_awaited()
async def test_모델이_확인을_건너뛰려_해도_소용없다(client, auth_headers, choose, db_engine):
"""응답에 needs_confirm 을 흉내 낼 칸을 주지 않았고, 등급은 레지스트리에서만 읽는다."""
h, pid = await seed(client, auth_headers)
choose({"tool": "publish", "args": {}, "message": "", "needs_confirm": False, "grade": "READ"})
res = await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "그냥 바로 발행해"})
assert res.json()["needs_confirm"] is True
async def test_확인_경로로_읽기_도구를_밀어넣을_수_없다(client, auth_headers, db_engine):
h, pid = await seed(client, auth_headers)
res = await client.post(
f"/v1/agent/chat/{pid}", headers=h, json={"confirm": {"tool": "없는도구", "args": {}}}
)
assert res.status_code == 409
assert res.json()["detail"] == "AGENT_UNKNOWN_TOOL"
# ── 3. 모호하면 실행하지 않는다 ──────────────────────────────────────────
async def test_도구를_못_고르면_되묻는다(client, auth_headers, choose, db_engine):
h, pid = await seed(client, auth_headers)
choose({"tool": "", "message": "어느 항목을 바꿀까요?"})
body = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "그거 좀 고쳐줘"})).json()
assert body["tool"] is None
assert body["reply"] == "어느 항목을 바꿀까요?"
assert body["needs_confirm"] is False
async def test_모델이_지어낸_도구는_실행되지_않는다(client, auth_headers, choose, db_engine):
h, pid = await seed(client, auth_headers)
choose({"tool": "delete_everything", "args": {}, "message": ""})
body = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "다 지워"})).json()
assert body["tool"] is None
async def test_없는_항목을_고르면_거절하고_이유를_말한다(client, auth_headers, choose, db_engine):
h, pid = await seed(client, auth_headers)
choose({"tool": "set_fact", "args": {"key": "메뉴명", "value": "고르곤졸라"}, "message": ""})
body = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "메뉴명 바꿔줘"})).json()
assert body.get("rejected") is True
assert "고칠 수 없" in body["reply"]
# ── 소유자 범위 ──────────────────────────────────────────────────────────
async def test_남의_가게는_없는_것과_똑같이_답한다(client, auth_headers, choose, db_engine):
"""★ 대화창이 소유자 스코프를 우회하는 유일한 입구가 되면 안 된다."""
_mine, pid = await seed(client, auth_headers, "내가게")
other = await auth_headers("agent-outsider")
choose({"tool": "list_facts", "args": {}, "message": ""})
res = await client.post(f"/v1/agent/chat/{pid}", headers=other, json={"message": "정보 보여줘"})
assert res.status_code == 404
assert res.json()["detail"] == "PLACE_NOT_FOUND"
async def test_로그인_없이는_열리지_않는다(client):
res = await client.post(f"/v1/agent/chat/{uuid.uuid4()}", json={"message": "안녕"})
assert res.status_code in (401, 403)
# ── 실행 결과 문구 ───────────────────────────────────────────────────────
async def test_값을_바꾸면_재발행이_필요하다고_말한다(client, auth_headers, choose, db_engine):
"""★ 이 한 줄이 빠지면 사장님은 반영된 줄 알고 확인하러 갔다가 옛 값을 본다."""
h, pid = await seed(client, auth_headers)
choose({"tool": "set_fact", "args": {"key": "check_in_time", "value": "15:00"}, "message": ""})
body = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "체크인 3시로"})).json()
assert body.get("rejected") is not True, body["reply"]
assert "체크인 시간" in body["reply"]
assert "발행" in body["reply"]
async with db_engine.begin() as c:
stored = (
await c.execute(
text("SELECT value FROM place_facts WHERE place_id=:p AND key='check_in_time' AND deleted=false"),
{"p": uuid.UUID(pid)},
)
).scalars().all()
assert "15:00" in stored
async def test_결과_문구는_모델이_쓰지_않는다(client, auth_headers, choose, db_engine):
"""모델이 결과를 쓰면 하지 않은 일을 했다고 말할 수 있다."""
h, pid = await seed(client, auth_headers)
choose({
"tool": "set_fact",
"args": {"key": "check_in_time", "value": "15:00"},
"message": "사이트까지 전부 반영을 끝냈습니다!",
})
body = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "체크인 3시로"})).json()
assert "전부 반영을 끝냈습니다" not in body["reply"]
# ── 실패 처리 ────────────────────────────────────────────────────────────
async def test_LLM_실패는_502_로_나가고_원문을_흘리지_않는다(client, auth_headers, monkeypatch, db_engine):
h, pid = await seed(client, auth_headers)
monkeypatch.setattr(runtime, "is_configured", lambda: True)
monkeypatch.setattr(runtime, "_choose", AsyncMock(side_effect=LlmError("키가 sk-1234 라서 실패")))
res = await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "안녕"})
assert res.status_code == 502
assert res.json()["detail"] == "AGENT_CALL_FAILED"
assert "sk-1234" not in res.text
async def test_키가_없으면_대화창을_열지_않는다(client, auth_headers, monkeypatch, db_engine):
h, pid = await seed(client, auth_headers)
monkeypatch.setattr(runtime, "is_configured", lambda: False)
res = await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "안녕"})
assert res.status_code == 409
assert res.json()["detail"] == "AGENT_NOT_CONFIGURED"
assert (await client.get("/v1/agent/status", headers=h)).json()["enabled"] is False
async def test_너무_긴_발화는_모델을_부르기_전에_끊는다(client, auth_headers, monkeypatch, db_engine):
h, pid = await seed(client, auth_headers)
called = AsyncMock()
monkeypatch.setattr(runtime, "_choose", called)
res = await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "가" * (runtime.MAX_MESSAGE + 1)})
assert res.status_code == 422 # pydantic 이 먼저 막는다
called.assert_not_awaited()
def test_읽기_도구는_확인을_요구하지_않는다():
for name in ("get_site_status", "list_facts"):
assert tools.REGISTRY[name].grade == ToolGrade.READ
assert tools.REGISTRY["set_fact"].grade == ToolGrade.REVERSIBLE
assert tools.REGISTRY["publish"].grade == ToolGrade.SEMI
assert tools.REGISTRY["publish"].confirm
# ── 보류 스위치 ─────────────────────────────────────────────────────────
def test_스위치와_키를_둘_다_본다(monkeypatch):
"""★ 키만 보면 '잠시 닫아 두기' 를 키를 지워서 해야 하고, 그러면 소개문·사진분류까지
같이 꺼진다. 스위치만 보면 키 없는 환경에서 **눌러도 안 되는 입구**가 생긴다."""
monkeypatch.setattr(runtime.provider, "active", lambda: SimpleNamespace(is_configured=lambda: True))
monkeypatch.delenv("AGENT_CHAT_ENABLED", raising=False)
assert runtime.is_configured() is True # 기본은 켜짐(2026-09-22)
monkeypatch.setenv("AGENT_CHAT_ENABLED", "0")
assert runtime.is_configured() is False # 스위치로 닫을 수 있다
monkeypatch.setenv("AGENT_CHAT_ENABLED", "1")
monkeypatch.setattr(runtime.provider, "active", lambda: SimpleNamespace(is_configured=lambda: False))
assert runtime.is_configured() is False # 켜 뒀어도 키가 없으면 안 열린다
async def test_닫아_두면_대화_요청은_거절된다(client, auth_headers, monkeypatch, db_engine):
monkeypatch.setenv("AGENT_CHAT_ENABLED", "0")
h, pid = await seed(client, auth_headers)
res = await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "안녕"})
assert res.status_code == 409
assert res.json()["detail"] == "AGENT_NOT_CONFIGURED"
assert (await client.get("/v1/agent/status", headers=h)).json()["enabled"] is False