From 1300652b09b68425259dd970ccea26ecf1cf0975 Mon Sep 17 00:00:00 2001 From: hbyang Date: Tue, 22 Sep 2026 16:26:40 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/backend:=20=EC=B9=B4=ED=86=A1?= =?UTF-8?q?=20=EB=8C=80=ED=99=94=EC=97=90=20=ED=99=88=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=AA=A9=EB=A1=9D=C2=B7=EA=B0=80=EA=B2=8C=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실제로 붙여 보니 빠진 것이 드러났다 — 연결은 됐는데 **어느 홈페이지를 다루는 대화인지** 말해 주지 않았다. 가게가 하나면 말없이 자동 선택돼 더 모호했다. - 연결 직후 목록을 보여준다. 하나면 이름+발행 여부를, 여럿이면 바로가기 버튼으로 - 목록 줄에 발행 여부를 적는다 — 안 그러면 고친 것이 손님에게 보이는 줄 안다 - "목록"·"가게 바꿔줘" 로 언제든 돌아와 바꾼다. ★ 이 경로는 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) --- docs/AGENT.md | 10 ++- docs/DEVLOG.md | 15 ++++ solution/backend/services/agent/channel.py | 88 +++++++++++++++----- solution/backend/tests/test_kakao_webhook.py | 56 +++++++++++++ 4 files changed, 147 insertions(+), 22 deletions(-) diff --git a/docs/AGENT.md b/docs/AGENT.md index 7e695e8..0ba682f 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -212,8 +212,14 @@ URL 만 알면 누구나 이 엔드포인트를 때릴 수 있고, `userRequest. | 대상 | `place_id` 가 URL 에 | 대화에서 고르고 `current_place_id` 에 기억 | | 확인 | 프론트가 `{confirm}` 을 되돌려 줌 | **서버가 무엇을 물었는지 들고 있는다** | -★ 가게가 여럿인데 안 정해졌으면 **추측하지 않고 되묻는다.** 임의로 첫 가게를 고르면 -사장님은 엉뚱한 가게를 고쳐 놓고도 그 사실을 모른다 — 대화에는 "지금 보고 있는 가게" 가 없다. +★ **연결되자마자 홈페이지 목록을 보여준다.** 연결만 알리고 끝내면 사장님은 어느 홈페이지를 +다루는 대화인지 모른 채 말을 걸게 된다. 목록에는 **발행 여부**를 같이 적는다 — 안 그러면 +고친 것이 손님에게 보이는 줄 안다. + +★ 가게가 여럿이면 **바로가기 버튼으로 고르게 한다.** 이름을 외워 치게 하지 않는다. +임의로 첫 가게를 고르지도 않는다 — 사장님은 엉뚱한 가게를 고쳐 놓고도 모른다. +"목록"·"가게 바꿔줘" 같은 말로 **언제든 돌아와 바꿀 수 있고**, 이 경로는 LLM 을 부르지 않는다 +(대화가 막혔을 때 처음 찾는 길이라 늘 통해야 하고, 목록 보기에 돈을 쓸 이유도 없다). ★ `pending_expires_at`(3분)이 없으면 **한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다.** 다른 말을 하면 그 말이 우선이고, 묵은 확인은 그 자리에서 치운다. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index a5dee69..620fd2e 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -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단계) 카카오톡 채널이 준비돼 웹훅을 만들었다. **런타임은 한 줄도 안 바뀌었다** — 채널을 모르게 diff --git a/solution/backend/services/agent/channel.py b/solution/backend/services/agent/channel.py index deddaae..28567ed 100644 --- a/solution/backend/services/agent/channel.py +++ b/solution/backend/services/agent/channel.py @@ -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: diff --git a/solution/backend/tests/test_kakao_webhook.py b/solution/backend/tests/test_kakao_webhook.py index 17c720b..3ea4aae 100644 --- a/solution/backend/tests/test_kakao_webhook.py +++ b/solution/backend/tests/test_kakao_webhook.py @@ -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)