[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:
parent
d32df10cf7
commit
1300652b09
@ -212,8 +212,14 @@ URL 만 알면 누구나 이 엔드포인트를 때릴 수 있고, `userRequest.
|
|||||||
| 대상 | `place_id` 가 URL 에 | 대화에서 고르고 `current_place_id` 에 기억 |
|
| 대상 | `place_id` 가 URL 에 | 대화에서 고르고 `current_place_id` 에 기억 |
|
||||||
| 확인 | 프론트가 `{confirm}` 을 되돌려 줌 | **서버가 무엇을 물었는지 들고 있는다** |
|
| 확인 | 프론트가 `{confirm}` 을 되돌려 줌 | **서버가 무엇을 물었는지 들고 있는다** |
|
||||||
|
|
||||||
★ 가게가 여럿인데 안 정해졌으면 **추측하지 않고 되묻는다.** 임의로 첫 가게를 고르면
|
★ **연결되자마자 홈페이지 목록을 보여준다.** 연결만 알리고 끝내면 사장님은 어느 홈페이지를
|
||||||
사장님은 엉뚱한 가게를 고쳐 놓고도 그 사실을 모른다 — 대화에는 "지금 보고 있는 가게" 가 없다.
|
다루는 대화인지 모른 채 말을 걸게 된다. 목록에는 **발행 여부**를 같이 적는다 — 안 그러면
|
||||||
|
고친 것이 손님에게 보이는 줄 안다.
|
||||||
|
|
||||||
|
★ 가게가 여럿이면 **바로가기 버튼으로 고르게 한다.** 이름을 외워 치게 하지 않는다.
|
||||||
|
임의로 첫 가게를 고르지도 않는다 — 사장님은 엉뚱한 가게를 고쳐 놓고도 모른다.
|
||||||
|
"목록"·"가게 바꿔줘" 같은 말로 **언제든 돌아와 바꿀 수 있고**, 이 경로는 LLM 을 부르지 않는다
|
||||||
|
(대화가 막혔을 때 처음 찾는 길이라 늘 통해야 하고, 목록 보기에 돈을 쓸 이유도 없다).
|
||||||
|
|
||||||
★ `pending_expires_at`(3분)이 없으면 **한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다.**
|
★ `pending_expires_at`(3분)이 없으면 **한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다.**
|
||||||
다른 말을 하면 그 말이 우선이고, 묵은 확인은 그 자리에서 치운다.
|
다른 말을 하면 그 말이 우선이고, 묵은 확인은 그 자리에서 치운다.
|
||||||
|
|||||||
@ -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단계)
|
## 2026-09-22 — 카카오 채널 웹훅(4단계)
|
||||||
|
|
||||||
카카오톡 채널이 준비돼 웹훅을 만들었다. **런타임은 한 줄도 안 바뀌었다** — 채널을 모르게
|
카카오톡 채널이 준비돼 웹훅을 만들었다. **런타임은 한 줄도 안 바뀌었다** — 채널을 모르게
|
||||||
|
|||||||
@ -23,6 +23,11 @@ from common.database.model.models import users
|
|||||||
from common.enums import DBWRType, ErrorType, KakaoLinkStatus
|
from common.enums import DBWRType, ErrorType, KakaoLinkStatus
|
||||||
from common.models.gmodel import UserInfo
|
from common.models.gmodel import UserInfo
|
||||||
from crud.place_crud import PlaceCRUD
|
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 import kakao_link_service as link_service
|
||||||
from services.agent import runtime
|
from services.agent import runtime
|
||||||
from services.agent.tools import REGISTRY
|
from services.agent.tools import REGISTRY
|
||||||
@ -43,6 +48,13 @@ DECLINE_LABEL = "아니요"
|
|||||||
_YES = {CONFIRM_LABEL, PUBLISH_LABEL, "네", "예", "응", "그래", "네 해주세요", "해주세요", "좋아", "ㅇㅇ", "확인"}
|
_YES = {CONFIRM_LABEL, PUBLISH_LABEL, "네", "예", "응", "그래", "네 해주세요", "해주세요", "좋아", "ㅇㅇ", "확인"}
|
||||||
_NO = {DECLINE_LABEL, "아니", "아니오", "안할래", "취소", "나중에", "ㄴㄴ"}
|
_NO = {DECLINE_LABEL, "아니", "아니오", "안할래", "취소", "나중에", "ㄴㄴ"}
|
||||||
|
|
||||||
|
# 언제든 목록으로 돌아오는 말. ★ LLM 을 부르지 않는다 — 목록 보기에 돈을 쓸 이유가 없고,
|
||||||
|
# "지금 어느 가게냐" 는 대화가 막혔을 때 가장 먼저 찾는 길이라 늘 통해야 한다.
|
||||||
|
_LIST_WORDS = {
|
||||||
|
"목록", "가게 목록", "사이트 목록", "내 사이트", "홈페이지 목록",
|
||||||
|
"가게 바꿔줘", "가게 변경", "다른 가게", "사이트 바꿔줘", "사이트 변경",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _now():
|
def _now():
|
||||||
return datetime.now(timezone.utc)
|
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)
|
await _update_link(key, pending_tool=None, pending_args=None, pending_expires_at=None)
|
||||||
|
|
||||||
|
|
||||||
async def _places(user: UserInfo) -> list:
|
async def _sites(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,
|
"가게가 있다" 가 아니라 "발행돼 있나 · 주소가 뭔가" 다 — `/sites` 화면이 같은 이유로
|
||||||
lambda s: PlaceCRUD().list_places(s, uuid.UUID(user.user_id), None, None, None, 0, 20),
|
`list_my_sites` 를 쓴다."""
|
||||||
)
|
service = SiteService(SiteCRUD(), PlaceCRUD(), JobQueue())
|
||||||
return list(rows or []) if err == ErrorType.SUCCESS else []
|
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):
|
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)"""
|
반환: (place_id, 되물을 답 or None)"""
|
||||||
places = await _places(user)
|
rows = await _sites(user)
|
||||||
if not places:
|
if not rows:
|
||||||
return None, _say("아직 등록된 가게가 없어요. 홈페이지를 먼저 만들어 주세요.")
|
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:
|
if chosen is not None:
|
||||||
await _update_link(row.channel_user_key, current_place_id=chosen.place_id,
|
await _update_link(row.channel_user_key, current_place_id=chosen.place_id,
|
||||||
pending_tool=None, pending_args=None, pending_expires_at=None)
|
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 len(rows) == 1:
|
||||||
if row.current_place_id != places[0].place_id:
|
if row.current_place_id != rows[0].place_id:
|
||||||
await _update_link(row.channel_user_key, current_place_id=places[0].place_id)
|
await _update_link(row.channel_user_key, current_place_id=rows[0].place_id)
|
||||||
return str(places[0].place_id), None
|
return str(rows[0].place_id), None
|
||||||
|
|
||||||
if row.current_place_id is not None:
|
if row.current_place_id is not None:
|
||||||
return str(row.current_place_id), 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:
|
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:
|
if not found:
|
||||||
return _say("먼저 홈페이지 관리자 화면의 [내 사이트]에서 카카오톡 연결 코드를 받아 보내 주세요.")
|
return _say("먼저 홈페이지 관리자 화면의 [내 사이트]에서 카카오톡 연결 코드를 받아 보내 주세요.")
|
||||||
try:
|
try:
|
||||||
await link_service.redeem(utterance, channel_user_key)
|
user_id = await link_service.redeem(utterance, channel_user_key)
|
||||||
except KakaoLinkError:
|
except KakaoLinkError:
|
||||||
# ★ 없는 코드·만료·시도 초과를 구분해 답하지 않는다(kakao_link_service 주석).
|
# ★ 없는 코드·만료·시도 초과를 구분해 답하지 않는다(kakao_link_service 주석).
|
||||||
return _say("코드가 맞지 않거나 시간이 지났어요. 새 코드를 받아 다시 보내 주세요.")
|
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)
|
user = await _user_info(row.user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
|
|||||||
@ -262,3 +262,59 @@ def test_바로가기는_열_개를_넘기지_않는다():
|
|||||||
assert len(out["template"]["quickReplies"]) == 10
|
assert len(out["template"]["quickReplies"]) == 10
|
||||||
assert out["version"] == "2.0"
|
assert out["version"] == "2.0"
|
||||||
assert out["template"]["outputs"][0]["simpleText"]["text"] == "안녕"
|
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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user