[feat] solution/backend: 카톡 대화에 홈페이지 목록·가게 고르기

실제로 붙여 보니 빠진 것이 드러났다 — 연결은 됐는데 **어느 홈페이지를 다루는
대화인지** 말해 주지 않았다. 가게가 하나면 말없이 자동 선택돼 더 모호했다.

- 연결 직후 목록을 보여준다. 하나면 이름+발행 여부를, 여럿이면 바로가기 버튼으로
- 목록 줄에 발행 여부를 적는다 — 안 그러면 고친 것이 손님에게 보이는 줄 안다
- "목록"·"가게 바꿔줘" 로 언제든 돌아와 바꾼다. ★ 이 경로는 LLM 을 부르지 않는다:
  대화가 막혔을 때 처음 찾는 길이라 늘 통해야 하고, 목록에 돈을 쓸 이유가 없다
- 사업장 목록이 아니라 list_my_sites 를 쓴다 — 사장님이 알아야 하는 건
  "가게가 있다" 가 아니라 "발행돼 있나" 다(/sites 화면이 같은 이유로 그걸 쓴다)

test_kakao_webhook.py 21 passed(목록·전환 4건 추가).
전체 845 passed / 53 failed — 53 은 이번 변경 전과 동일

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-09-22 16:26:40 +09:00
parent d32df10cf7
commit 1300652b09
4 changed files with 147 additions and 22 deletions

View File

@ -212,8 +212,14 @@ URL 만 알면 누구나 이 엔드포인트를 때릴 수 있고, `userRequest.
| 대상 | `place_id` 가 URL 에 | 대화에서 고르고 `current_place_id` 에 기억 |
| 확인 | 프론트가 `{confirm}` 을 되돌려 줌 | **서버가 무엇을 물었는지 들고 있는다** |
★ 가게가 여럿인데 안 정해졌으면 **추측하지 않고 되묻는다.** 임의로 첫 가게를 고르면
사장님은 엉뚱한 가게를 고쳐 놓고도 그 사실을 모른다 — 대화에는 "지금 보고 있는 가게" 가 없다.
**연결되자마자 홈페이지 목록을 보여준다.** 연결만 알리고 끝내면 사장님은 어느 홈페이지를
다루는 대화인지 모른 채 말을 걸게 된다. 목록에는 **발행 여부**를 같이 적는다 — 안 그러면
고친 것이 손님에게 보이는 줄 안다.
★ 가게가 여럿이면 **바로가기 버튼으로 고르게 한다.** 이름을 외워 치게 하지 않는다.
임의로 첫 가게를 고르지도 않는다 — 사장님은 엉뚱한 가게를 고쳐 놓고도 모른다.
"목록"·"가게 바꿔줘" 같은 말로 **언제든 돌아와 바꿀 수 있고**, 이 경로는 LLM 을 부르지 않는다
(대화가 막혔을 때 처음 찾는 길이라 늘 통해야 하고, 목록 보기에 돈을 쓸 이유도 없다).
`pending_expires_at`(3분)이 없으면 **한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다.**
다른 말을 하면 그 말이 우선이고, 묵은 확인은 그 자리에서 치운다.

View File

@ -1,5 +1,20 @@
# 개발 일지
## 2026-09-22 — 카톡 대화에 홈페이지 목록·가게 고르기
실제로 붙여 보니 빠진 것이 드러났다(사장님 지적): 연결은 됐는데 **어느 홈페이지를 다루는
대화인지 화면이 말해 주지 않았다.** 가게가 하나면 말없이 자동 선택돼 더 모호했다.
- 연결 직후 목록을 보여준다. 하나면 그 이름과 발행 여부를, 여럿이면 **바로가기 버튼**으로 고르게.
- 목록 줄에 **발행 여부**를 적는다 — 안 그러면 고친 것이 손님에게 보이는 줄 안다.
- "목록"·"가게 바꿔줘" 등으로 **언제든 돌아와 바꾼다.** ★ 이 경로는 LLM 을 부르지 않는다 —
대화가 막혔을 때 처음 찾는 길이라 늘 통해야 하고, 목록 보기에 돈을 쓸 이유가 없다.
- 목록은 `list_my_sites` 를 쓴다(사업장 목록이 아니라). `/sites` 화면이 같은 이유로 그걸 쓴다 —
사장님이 알아야 하는 건 "가게가 있다" 가 아니라 "발행돼 있나" 다.
**검증** — `test_kakao_webhook.py` 21 passed(목록·전환 4건 추가).
전체 `845 passed / 53 failed`, 53 은 이번 변경 전과 같다.
## 2026-09-22 — 카카오 채널 웹훅(4단계)
카카오톡 채널이 준비돼 웹훅을 만들었다. **런타임은 한 줄도 안 바뀌었다** — 채널을 모르게

View File

@ -23,6 +23,11 @@ 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
@ -43,6 +48,13 @@ DECLINE_LABEL = "아니요"
_YES = {CONFIRM_LABEL, PUBLISH_LABEL, "", "", "", "그래", "네 해주세요", "해주세요", "좋아", "ㅇㅇ", "확인"}
_NO = {DECLINE_LABEL, "아니", "아니오", "안할래", "취소", "나중에", "ㄴㄴ"}
# 언제든 목록으로 돌아오는 말. ★ LLM 을 부르지 않는다 — 목록 보기에 돈을 쓸 이유가 없고,
# "지금 어느 가게냐" 는 대화가 막혔을 때 가장 먼저 찾는 길이라 늘 통해야 한다.
_LIST_WORDS = {
"목록", "가게 목록", "사이트 목록", "내 사이트", "홈페이지 목록",
"가게 바꿔줘", "가게 변경", "다른 가게", "사이트 바꿔줘", "사이트 변경",
}
def _now():
return datetime.now(timezone.utc)
@ -112,14 +124,31 @@ async def _clear_pending(key):
await _update_link(key, pending_tool=None, pending_args=None, pending_expires_at=None)
async def _places(user: UserInfo) -> list:
# ★ (err, rows, total) 셋으로 풀린다 — place_service.list_places 와 같은 호출 모양이다.
err, rows, _total = await DB_SESSION_MNG.execute_lambda(
Link.DBType(),
DBWRType.DB_READ.value,
lambda s: PlaceCRUD().list_places(s, uuid.UUID(user.user_id), None, None, None, 0, 20),
)
return list(rows or []) if err == ErrorType.SUCCESS else []
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):
@ -129,27 +158,33 @@ async def _pick_place(user: UserInfo, row, utterance: str):
고쳐 놓고도 사실을 모른다 화면과 달리 대화에는 "지금 보고 있는 가게" 없다.
반환: (place_id, 되물을 or None)"""
places = await _places(user)
if not places:
rows = await _sites(user)
if not rows:
return None, _say("아직 등록된 가게가 없어요. 홈페이지를 먼저 만들어 주세요.")
names = {p.name.strip(): p for p in places}
# ★ 언제든 목록으로 돌아올 수 있어야 한다. 대화가 막혔을 때 처음 찾는 길이다.
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 = names.get(utterance.strip())
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}' 으로 맞췄습니다. 무엇을 도와드릴까요?")
return None, _say(f"'{chosen.name}' 으로 맞췄습니다. 무엇을 도와드릴까요?\n"
f"예) 체크인 시간 3시로 바꿔줘")
if len(places) == 1:
if row.current_place_id != places[0].place_id:
await _update_link(row.channel_user_key, current_place_id=places[0].place_id)
return str(places[0].place_id), None
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, _say("어느 가게 이야기일까요?", [p.name for p in places[:10]])
return None, _list_reply(rows, "관리 중인 홈페이지입니다.")
async def handle(utterance: str, channel_user_key: str) -> dict:
@ -166,11 +201,24 @@ async def handle(utterance: str, channel_user_key: str) -> dict:
if not found:
return _say("먼저 홈페이지 관리자 화면의 [내 사이트]에서 카카오톡 연결 코드를 받아 보내 주세요.")
try:
await link_service.redeem(utterance, channel_user_key)
user_id = await link_service.redeem(utterance, channel_user_key)
except KakaoLinkError:
# ★ 없는 코드·만료·시도 초과를 구분해 답하지 않는다(kakao_link_service 주석).
return _say("코드가 맞지 않거나 시간이 지났어요. 새 코드를 받아 다시 보내 주세요.")
return _say("연결됐습니다. 이제 여기서 홈페이지를 고칠 수 있어요.\n예) 체크인 시간 3시로 바꿔줘")
# ★ 연결만 알리고 끝내지 않는다. 사장님은 **어느 홈페이지를 다루는 대화인지** 모른 채
# 말을 걸게 되고, 가게가 둘 이상이면 첫 마디부터 되묻기에 걸린다.
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:

View File

@ -262,3 +262,59 @@ def test_바로가기는_열_개를_넘기지_않는다():
assert len(out["template"]["quickReplies"]) == 10
assert out["version"] == "2.0"
assert out["template"]["outputs"][0]["simpleText"]["text"] == "안녕"
# ── 사이트 목록 · 고르기 ─────────────────────────────────────────────────
async def test_연결되자마자_홈페이지_목록을_알려준다(client, auth_headers, db_engine):
"""★ 연결만 알리고 끝내면 사장님은 **어느 홈페이지를 다루는 대화인지** 모른 채 말을 건다."""
h, pid = await owner_with_place(client, auth_headers, "첫째가게")
await client.post("/v1/place", headers=h, json={"name": "둘째가게", "category": 1})
uid = await user_id_of(db_engine, pid)
code = (await link_service.issue_code(uid))["code"]
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body(code, "목록-발화자"))
text_out = said(res)
assert "연결됐습니다" in text_out
assert "첫째가게" in text_out and "둘째가게" in text_out
# 바로 고를 수 있어야 한다 — 이름을 외워 치게 하지 않는다.
assert set(quick(res)) == {"첫째가게", "둘째가게"}
async def test_가게가_하나면_그_이름을_말해_준다(client, auth_headers, db_engine):
h, pid = await owner_with_place(client, auth_headers, "혼자가게")
uid = await user_id_of(db_engine, pid)
code = (await link_service.issue_code(uid))["code"]
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body(code, "하나-발화자"))
assert "혼자가게" in said(res)
assert "발행" in said(res) # 발행 여부를 같이 말한다
async def test_목록이라고_하면_언제든_다시_보여주고_가게를_바꿀_수_있다(client, auth_headers, db_engine, monkeypatch):
"""★ 대화가 막혔을 때 사장님이 처음 찾는 길이다. LLM 을 부르지 않는다."""
speaker = "전환-발화자"
h, _pid, _uid = await link(db_engine, client, auth_headers, speaker, "첫째가게")
await client.post("/v1/place", headers=h, json={"name": "둘째가게", "category": 1})
called = AsyncMock()
monkeypatch.setattr(runtime, "chat", called)
# 먼저 한 곳을 고른다.
await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("첫째가게", speaker))
# 목록으로 돌아온다.
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("가게 바꿔줘", speaker))
assert "첫째가게" in said(res) and "둘째가게" in said(res)
assert set(quick(res)) == {"첫째가게", "둘째가게"}
called.assert_not_awaited() # 목록 보기에 모델을 부르지 않는다
# 다른 곳으로 바꾼다.
picked = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("둘째가게", speaker))
assert "둘째가게" in said(picked)
async def test_목록은_발행_여부를_같이_말한다(client, auth_headers, db_engine):
"""★ 안 그러면 사장님은 고친 것이 손님에게 보이는 줄 안다."""
speaker = "발행표시-발화자"
await link(db_engine, client, auth_headers, speaker, "미발행가게")
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("목록", speaker))
assert "아직 발행 전" in said(res)