o2o-site-AEO/solution/backend/services/agent/channel.py
hbyang bbcdf78c62 [fix] solution/backend: 카톡 응답이 5초 벽에 계속 걸리던 것 — 프롬프트 축소
실사용에서 모든 발화가 4.5초 상한에 걸려 "확인하는 데 시간이 조금 걸리네요" 만
반복됐다(킹서버 로그: 4.60s · 4.51s · 4.51s).

- 이미 연결된 사람이 코드를 또 보내면 LLM 을 부르지 않는다. 실제로 그랬고,
  6자리가 그냥 발화로 넘어가 유료 호출 + 대기만 쌓였다
- 사이트 상태를 프롬프트에서 뺀다. 그 한 줄 때문에 매 턴 사이트 조회 + 슬러그
  계산이 돌았고, 정작 필요할 때는 get_site_status 도구를 부르면 된다
- fact 는 key:value 만, 상한 30개. label 은 항목 목록에 이미 있어 두 번 보내면
  프롬프트만 커지고 모델이 얻는 것이 없다
- 항목 목록도 JSON 대신 `key: 이름` 줄로

★ 근본 해결은 콜백이다(f500210). 오픈빌더에서 '콜백 사용' 이 꺼져 있으면
callbackUrl 이 안 와서 조용히 동기 경로로만 돈다 — 지금 로그가 그 상태다.

test_kakao_webhook·test_agent_runtime 43 passed

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

289 lines
14 KiB
Python

"""메신저 대화 한 턴 — 신원 · 가게 고르기 · 확인 이어받기.
★★ **카카오를 모른다.** `version: "2.0"` · `simpleText` 같은 형식은 한 글자도 여기 없다.
그건 `router/v1/agent/kakao_bot.py` 안에서 끝난다 — 새면 다른 채널을 붙일 때 전부
걷어내야 하고, 알림톡 어댑터에 건 것과 같은 규칙이다.
★ 빌더 화면과 무엇이 다른가 — 셋뿐이다.
1. 로그인 토큰이 없다 → 연결된 발화자 키로 사장님을 찾는다
2. place_id 가 URL 에 없다 → 대화에서 고르고 기억한다
3. 확인을 되돌려 줄 프론트가 없다 → 무엇을 물었는지 서버가 들고 있는다
나머지(도구·등급·게이트)는 `runtime.chat()` 그대로다.
"""
import re
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import owner_kakao_links as Link
from common.database.model.models import users
from common.enums import DBWRType, ErrorType, KakaoLinkStatus
from common.models.gmodel import UserInfo
from crud.place_crud import PlaceCRUD
from crud.site_crud import SiteCRUD
from crud.job_crud import JobQueue
from common.enums import SiteStatus
from common.models.gmodel import PageParams
from services.site_service import SiteService
from services import kakao_link_service as link_service
from services.agent import runtime
from services.agent.tools import REGISTRY
from services.kakao_link_service import KakaoLinkError
# 연결 코드 모양(kakao_link_service._CODE_ALPHABET 과 같은 글자 집합).
CODE_PATTERN = re.compile(r"[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}")
# 확인 대기 수명. ★ 이게 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다.
PENDING_MINUTES = 3
# ★ 바로가기 라벨과 '예' 로 읽는 말이 어긋나면 **눌러도 안 먹는다** — 사장님은 버튼이
# 고장난 줄 안다. 라벨을 상수로 두고 _YES 가 그것을 포함하게 묶는다.
CONFIRM_LABEL = "네, 해주세요"
PUBLISH_LABEL = "네, 발행해주세요"
DECLINE_LABEL = "아니요"
_YES = {CONFIRM_LABEL, PUBLISH_LABEL, "", "", "", "그래", "네 해주세요", "해주세요", "좋아", "ㅇㅇ", "확인"}
_NO = {DECLINE_LABEL, "아니", "아니오", "안할래", "취소", "나중에", "ㄴㄴ"}
# 언제든 목록으로 돌아오는 말. ★ LLM 을 부르지 않는다 — 목록 보기에 돈을 쓸 이유가 없고,
# "지금 어느 가게냐" 는 대화가 막혔을 때 가장 먼저 찾는 길이라 늘 통해야 한다.
_LIST_WORDS = {
"목록", "가게 목록", "사이트 목록", "내 사이트", "홈페이지 목록",
"가게 바꿔줘", "가게 변경", "다른 가게", "사이트 바꿔줘", "사이트 변경",
}
def _now():
return datetime.now(timezone.utc)
def _say(text: str, quick: list[str] | None = None) -> dict:
"""채널이 모르는 모양으로 답한다 — 문구와 바로가기 목록뿐이다."""
return {"text": text, "quick_replies": quick or []}
async def _user_info(user_id) -> UserInfo | None:
"""user_id → UserInfo. ★ 토큰을 발급하지 않는다.
프로세스 안에서 쓸 객체만 만든다 — 카톡 경로에서 JWT 가 나오면 그게 곧 권한 탈취
경로다(docs/AGENT.md)."""
async def run(s):
row = (await s.execute(select(users).where(users.user_id == user_id, users.deleted.is_(False)))).scalars().first()
return ErrorType.SUCCESS, row
# ★ execute_lambda 는 람다 반환값을 **그대로** 준다. CRUD 관례(ErrorType, 값)를 따라
# 우리 람다도 같은 모양으로 돌려준다 — 안 맞추면 여기서 TypeError 로 조용히 죽는다.
err, row = await DB_SESSION_MNG.execute_lambda(users.DBType(), DBWRType.DB_READ.value, run)
if err != ErrorType.SUCCESS or row is None:
return None
return UserInfo(user_id=str(row.user_id), id=row.id, role=row.role, token_version=row.token_version)
async def _link_row(channel_user_key: str):
async def run(s):
row = (
await s.execute(
select(Link).where(
Link.channel_user_key == channel_user_key,
Link.deleted.is_(False),
Link.status == KakaoLinkStatus.LINKED.value,
)
)
).scalars().first()
return ErrorType.SUCCESS, row
_err, row = await DB_SESSION_MNG.execute_lambda(Link.DBType(), DBWRType.DB_READ.value, run)
return row
async def _update_link(channel_user_key: str, **values):
async def run(s):
row = (
await s.execute(
select(Link).where(
Link.channel_user_key == channel_user_key,
Link.deleted.is_(False),
Link.status == KakaoLinkStatus.LINKED.value,
)
)
).scalars().first()
if row is None:
return None
for name, value in values.items():
setattr(row, name, value)
return row
await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
async def _clear_pending(key):
await _update_link(key, pending_tool=None, pending_args=None, pending_expires_at=None)
async def _sites(user: UserInfo) -> list:
"""사장님의 가게 + 그 사이트 상태를 한 번에.
★ 사업장 목록이 아니라 **사이트 목록**을 쓴다. 대화에서 사장님이 알아야 하는 것은
"가게가 있다" 가 아니라 "발행돼 있나 · 주소가 뭔가" 다 — `/sites` 화면이 같은 이유로
`list_my_sites` 를 쓴다."""
service = SiteService(SiteCRUD(), PlaceCRUD(), JobQueue())
res = await service.list_my_sites(user, PageParams(page=1, size=20))
return list(res.sites or [])
def _line(row) -> str:
"""목록 한 줄. ★ 발행 여부를 같이 말한다 — 안 그러면 사장님은 고친 것이 손님에게
보이는 줄 안다."""
if row.status == SiteStatus.PUBLISHED and row.published_at:
when = row.published_at.strftime("%m월 %d")
return f"· {row.name}{when} 발행"
return f"· {row.name} — 아직 발행 전"
def _list_reply(rows: list, head: str) -> dict:
body = "\n".join(_line(r) for r in rows[:10])
more = f"\n(그 밖에 {len(rows) - 10}곳 더)" if len(rows) > 10 else ""
tail = "\n\n어느 가게 이야기일까요?" if len(rows) > 1 else ""
return _say(f"{head}\n{body}{more}{tail}", [r.name for r in rows[:10]] if len(rows) > 1 else [])
async def _pick_place(user: UserInfo, row, utterance: str):
"""어느 가게 이야기인지 정한다.
★ 여럿인데 안 정해졌으면 **되묻는다.** 임의로 첫 가게를 고르면, 사장님은 엉뚱한 가게를
고쳐 놓고도 그 사실을 모른다 — 화면과 달리 대화에는 "지금 보고 있는 가게" 가 없다.
반환: (place_id, 되물을 답 or None)"""
rows = await _sites(user)
if not rows:
return None, _say("아직 등록된 가게가 없어요. 홈페이지를 먼저 만들어 주세요.")
# ★ 언제든 목록으로 돌아올 수 있어야 한다. 대화가 막혔을 때 처음 찾는 길이다.
if utterance in _LIST_WORDS:
await _update_link(row.channel_user_key, current_place_id=None,
pending_tool=None, pending_args=None, pending_expires_at=None)
return None, _list_reply(rows, "관리 중인 홈페이지입니다.")
# 바로가기를 눌렀거나 가게 이름을 그대로 말한 경우 — 그 가게로 맞춘다.
chosen = {r.name.strip(): r for r in rows}.get(utterance.strip())
if chosen is not None:
await _update_link(row.channel_user_key, current_place_id=chosen.place_id,
pending_tool=None, pending_args=None, pending_expires_at=None)
return None, _say(f"'{chosen.name}' 으로 맞췄습니다. 무엇을 도와드릴까요?\n"
f"예) 체크인 시간 3시로 바꿔줘")
if len(rows) == 1:
if row.current_place_id != rows[0].place_id:
await _update_link(row.channel_user_key, current_place_id=rows[0].place_id)
return str(rows[0].place_id), None
if row.current_place_id is not None:
return str(row.current_place_id), None
return None, _list_reply(rows, "관리 중인 홈페이지입니다.")
async def handle(utterance: str, channel_user_key: str) -> dict:
"""대화 한 턴. 예외를 던지지 않는다 — 메신저에서는 500 도 침묵으로 보인다."""
utterance = (utterance or "").strip()
if not utterance:
return _say("무엇을 도와드릴까요?")
row = await _link_row(channel_user_key)
# ── 아직 연결되지 않은 발화자 ─────────────────────────────────────────
if row is None:
found = CODE_PATTERN.fullmatch(utterance.upper())
if not found:
return _say("먼저 홈페이지 관리자 화면의 [내 사이트]에서 카카오톡 연결 코드를 받아 보내 주세요.")
try:
user_id = await link_service.redeem(utterance, channel_user_key)
except KakaoLinkError:
# ★ 없는 코드·만료·시도 초과를 구분해 답하지 않는다(kakao_link_service 주석).
return _say("코드가 맞지 않거나 시간이 지났어요. 새 코드를 받아 다시 보내 주세요.")
# ★ 연결만 알리고 끝내지 않는다. 사장님은 **어느 홈페이지를 다루는 대화인지** 모른 채
# 말을 걸게 되고, 가게가 둘 이상이면 첫 마디부터 되묻기에 걸린다.
user = await _user_info(user_id)
rows = await _sites(user) if user else []
if not rows:
return _say("연결됐습니다. 아직 등록된 가게가 없어요 — 홈페이지를 먼저 만들어 주세요.")
if len(rows) == 1:
await _update_link(channel_user_key, current_place_id=rows[0].place_id)
return _say(
f"연결됐습니다. '{rows[0].name}' 홈페이지를 여기서 고칠 수 있어요.\n"
f"{_line(rows[0])}\n\n예) 체크인 시간 3시로 바꿔줘"
)
return _list_reply(rows, "연결됐습니다. 관리 중인 홈페이지입니다.")
user = await _user_info(row.user_id)
if user is None:
return _say("계정을 찾지 못했어요. 관리자 화면에서 다시 연결해 주세요.")
# ★ 이미 연결된 사람이 코드를 또 보내는 일이 실제로 있었다(2026-09-22). 그대로 두면
# 6자리가 그냥 발화로 모델에 넘어가 유료 호출 + 대기만 쌓인다 — 여기서 끊는다.
if CODE_PATTERN.fullmatch(utterance.upper()):
return _say("이미 연결되어 있어요. 바로 말씀하시면 됩니다.\n예) 체크인 시간 3시로 바꿔줘")
# ── 확인 이어받기 ────────────────────────────────────────────────────
pending = None
if row.pending_tool and row.pending_expires_at and row.pending_expires_at > _now():
pending = {"tool": row.pending_tool, "args": row.pending_args or {}}
elif row.pending_tool:
# 만료. 조용히 흘리지 않고 치운다 — 남아 있으면 다음 "네" 가 그걸 집는다.
await _clear_pending(channel_user_key)
if pending is not None:
if utterance in _YES:
await _clear_pending(channel_user_key)
result = await runtime.chat(user, str(row.current_place_id), "", confirm=pending)
return _say(result["reply"])
if utterance in _NO:
await _clear_pending(channel_user_key)
return _say("알겠습니다. 그대로 두겠습니다.")
# 다른 말을 했으면 그 말이 우선이다. 묵은 확인을 들고 있지 않는다.
await _clear_pending(channel_user_key)
# ── 가게 고르기 ──────────────────────────────────────────────────────
place_id, ask = await _pick_place(user, row, utterance)
if ask is not None:
return ask
# ── 도구 ─────────────────────────────────────────────────────────────
try:
result = await runtime.chat(user, place_id, utterance)
except runtime.AgentError as ex:
return _say(_ERRORS.get(str(ex), "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요."))
if result.get("needs_confirm") and result.get("tool"):
await _update_link(
channel_user_key,
pending_tool=result["tool"],
pending_args=result.get("args") or {},
pending_expires_at=_now() + timedelta(minutes=PENDING_MINUTES),
)
return _say(result["reply"], [CONFIRM_LABEL, DECLINE_LABEL])
# 값을 고쳤으면 재발행을 바로 누를 수 있게 바로가기를 붙인다 — 도구가 이미 그렇게 묻는다.
quick = [PUBLISH_LABEL, DECLINE_LABEL] if result.get("done") and result.get("tool") != REGISTRY["publish"].name else []
if quick:
await _update_link(
channel_user_key,
pending_tool="publish",
pending_args={},
pending_expires_at=_now() + timedelta(minutes=PENDING_MINUTES),
)
return _say(result["reply"], quick)
_ERRORS = {
"PLACE_NOT_FOUND": "그 가게를 찾지 못했어요.",
"AGENT_NOT_CONFIGURED": "지금은 대화 기능이 꺼져 있어요.",
"AGENT_MESSAGE_TOO_LONG": "말씀이 조금 길어요. 짧게 나눠서 말씀해 주세요.",
"AGENT_CALL_FAILED": "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요.",
}