카카오톡 채널 개설이 법인폰 본인인증에 걸려 보류됐다. 채널이 없으면 대화창은 사장님에게 **어디에도 닿지 않는 입구**이고, 열려 있으면 "되는 기능" 으로 오해한다. - config/agent_config: AGENT_CHAT_ENABLED 신설(기본 0) - runtime.is_configured(): 스위치와 LLM 키를 둘 다 본다 — 화면을 우회해 API 를 직접 불러도 AGENT_NOT_CONFIGURED 다 - AgentChatDock · KakaoChannelCard: 조건 미충족이면 통째로 감춘다(return null). 연결 카드는 connection_enabled 가 기준이라 설정만 채우면 그대로 다시 나타난다 - ★ 코드를 지우지 않았다 — 되돌릴 때 커밋을 되짚지 않고 값 둘만 채우면 된다 ★ Threads 카드와 판단이 갈린 것이 맞다. 저쪽은 사장님이 곧 쓸 수 있는 기능이라 자리를 두고 버튼만 죽였고, 이쪽은 언제 열릴지 말해 줄 수 없다. test_agent_runtime(스위치 2건 추가)·test_kakao_link 34 passed. npm run lint 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
249 lines
12 KiB
Python
249 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 False
|
|
monkeypatch.setenv("AGENT_CHAT_ENABLED", "1")
|
|
assert runtime.is_configured() is True
|
|
monkeypatch.setenv("AGENT_CHAT_ENABLED", "0")
|
|
assert runtime.is_configured() is False
|
|
|
|
|
|
async def test_꺼진_동안_대화_요청은_거절된다(client, auth_headers, monkeypatch, db_engine):
|
|
monkeypatch.delenv("AGENT_CHAT_ENABLED", raising=False)
|
|
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
|