o2o-site-AEO/docs/AGENT.md
hbyang ca0bea77a7 [feat] solution/backend: 카카오 로그인 — 회원번호로 카톡 채널을 코드 없이 잇는다
★★ 챗봇 웹훅의 user.properties.appUserId 는 카카오 로그인의 회원번호와 **같은 값**이다
(카카오 공식 문서, 봇에 앱키가 물려 있을 때). 그래서 카카오로 로그인만 해 두면 채널에
말을 거는 순간 누구인지 알 수 있고, 6자리 코드 절차가 필요 없어진다.

- external/kakao_identity: 액세스 토큰을 카카오에 되물어 확인한다. ★ 응답의 app_id 를
  우리 앱과 대조하는 것이 구글의 aud 검사에 해당한다 — 이게 없으면 남의 앱 토큰으로
  우리 계정이 된다. 이름·이메일은 동의 항목이라 못 받아도 로그인은 되게 했다
- auth_service.kakao_login: google_login 과 같은 세 갈래. 이메일이 겹쳐도 자동으로
  잇지 않는다(DECISIONS 1 — 계정 선점)
- kakao_link_service.link_by_app_user_id: 자동 매칭. ★ 이미 다른 사장님에게 묶인
  카톡은 빼앗지 않는다 — 조용히 빼앗으면 앞사람이 남의 가게를 보게 된다
- ★ 코드 경로는 그대로 둔다: id/pw·구글 가입자에겐 appUserId 가 없고, 봇에 앱키가
  안 물린 환경에서는 값 자체가 안 온다

★ 함께 고친 것 — services/agent/tools.py 가 사라진 site_payload._DEFAULT_THEME 를
보고 있었다(c690862 템플릿 정의 통합에서 이름이 없어졌는데 이 한 줄만 남았다).
**대화의 섹션 기능이 통째로 죽어 있었고** 웹훅이 AttributeError 를 삼켜 "지금은
처리할 수 없어요" 로만 보였다 — common/template_catalog.industry_of 로 바꿨다.

test_kakao_link·test_kakao_webhook·test_agent_runtime·test_auth 194 passed
(신규 4: 자동 매칭·미가입자·빼앗지 않음·재진입). 남은 1건은 컨테이너에 실제
GOOGLE_CLIENT_ID 가 있어 나는 기존 실패다.

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

36 KiB

사장님 에이전트 — 신원 연결 · 도구 · 런타임

사장님이 말로 무엇을 시킬 수 있는지(운영자·CS 용 목록)는 AGENT_GUIDE.md. 이 문서는 왜 그렇게 동작하는지를 다룬다.

사장님이 말로 사이트를 운영하는 것이 목표다 — 내용 고치기, 사진 내리기, 발행, SNS 게재까지. 에이전트는 카카오톡 안에 있지 않다. 카톡은 입구 하나이고, 같은 에이전트가 빌더 화면에도 붙는다. 그래야 채널·챗봇 심사 전에 전부 검증된다.

1단계(신원 연결) · 2단계(도구·런타임·빌더 채팅창) · 4단계(카카오 웹훅) 을 만들었다. 남은 것은 오픈빌더 챗봇 등록(우리가 못 하는 일)과 도구 늘리기다.

화면 스위치

화면 여는 조건 지금
대화창(AgentChatDock) AGENT_CHAT_ENABLED=1(기본) 그리고 LLM 키 열림
연결 카드(KakaoChannelCard) KAKAO_CHANNEL_PUBLIC_ID 가 채워짐 채널 ID 미설정

★ 스위치와 키를 둘 다 본다(runtime.is_configured). 키만 보면 "잠시 닫아 두기" 를 키를 지워서 해야 하고 그러면 소개문·사진분류까지 같이 꺼진다. 스위치만 보면 키 없는 환경에서 눌러도 안 되는 입구가 생긴다.

★ 2026-09-21 에 카카오 채널 개설이 법인폰 본인인증에 걸려 한 번 닫았고, 인증이 끝나 2026-09-22 에 다시 열었다. 그때도 코드는 지우지 않고 값만 바꿨다 — 닫고 여는 일이 커밋을 되짚는 일이 되면 안 된다.

★ 연결 카드를 '감추는' 쪽으로 둔 것은 Threads 카드('자리는 두고 버튼만 죽인다')와 반대 판단인데 의도한 차이다. 저쪽은 사장님이 곧 쓸 수 있는 기능이라 존재를 알려야 했고, 이쪽은 웹훅(4단계)이 없어 아직 연결이 완성되지 않는다.

왜 신원 연결이 먼저인가

카카오 채널이 주는 발화자 식별자는 채널 단위 익명 키다. 우리 user_id 와 아무 관계가 없다.

이 레포의 모든 엔드포인트는 place_crud.get_place(s, owner_user_id, place_id) 로 "없는 것과 남의 것을 똑같이 PLACE_NOT_FOUND 로 답하는" 관례를 지킨다. 채널에서 온 발화에는 그 owner_user_id 를 줄 근거가 없다 — 연결 절차가 없으면 채널 진입점만 소유자 범위 밖에 놓이고, 채널에 말을 건 아무나가 남의 가게를 고친다.

카카오 로그인으로 가입했으면 — 코드가 필요 없다 (2026-09-30)

★★ 챗봇 웹훅의 user.properties.appUserId 는 카카오 로그인의 회원번호와 같은 값이다 (카카오 공식 문서, 봇에 앱키가 물려 있을 때만 온다). 그래서 카카오로 로그인만 해 두면 채널에 말을 거는 순간 누구인지 알 수 있다 — kakao_link_service.link_by_app_user_id.

★ 같은 앱이어야 한다. 로그인 앱과 봇에 물린 앱이 다르면 회원번호가 달라서 로그인은 되는데 매칭만 조용히 안 된다. 증상이 안 보이는 종류다.

★ 코드 경로를 지우지 않는다. id/pw·구글로 가입한 사장님에게는 appUserId 가 없고, 봇에 앱키가 안 물린 환경에서는 그 값 자체가 오지 않는다 — 그때 유일한 길이다.

★ 그 카톡이 이미 다른 사장님에게 묶여 있으면 잇지 않는다. 조용히 빼앗으면 앞사람이 남의 가게를 보게 된다.

절차 — 사장님은 두 번 누른다

  1. /sites 내 사이트 화면의 카카오톡으로 관리 · 채널 연결 카드 → [카카오톡 연결]
  2. 화면에 뜬 6자리 코드를 카카오톡 채널에 보낸다

★ 연결 버튼을 사업장 화면에 두지 않는다. 연결은 user 단위인데 버튼이 사업장 안에 있으면 사장님은 업장마다 연결해야 하는 줄 안다(SocialConnectionCard 가 같은 이유로 거기 있다).

★ KAKAO_CHANNEL_PUBLIC_ID 가 비면 카드는 그리되 버튼이 죽는다. 어디에 코드를 칠지 말해 줄 수 없는데 코드만 발급하면 사장님에게는 고장난 화면이다. 숨기지는 않는다 — 숨기면 기능이 없는 것처럼 보인다(2026-09-14 Threads 카드에서 실제로 겪었다).

user_id · channel_user_key · code_sha · code_expires_at · code_attempts · status · linked_at · last_seen_at

인덱스 무엇을 막나
uq_kakao_link_user (PENDING·LINKED) 한 사장님에 활성 연결 하나. 다시 눌러도 행이 늘지 않고 코드만 바뀐다
uq_kakao_link_channel_key (LINKED) ★ 한 카카오 계정은 한 사장님에만. 없으면 "어느 가게 이야기냐" 가 대화가 아니라 DB 에서 갈라진다
uq_kakao_link_code (PENDING) 코드 한 행 지목

코드는 평문으로 저장하지 않는다(code_sha). 사장님이 손으로 치는 짧은 값이라, 평문이면 DB 를 읽을 수 있는 쪽이 곧 연결 권한을 갖는다. 그래서 화면에 한 번 뜨고 다시 볼 수 없다 — 카드는 항상 [코드 다시 받기] 를 함께 둔다.

코드 글자에서 0·O·1·I·L 을 뺐다. 잘못 읽어 실패하면 원인이 화면에 안 보이고 "연결이 안 된다" 로만 보인다.

일회성은 값이 아니라 CAS 가 보장한다

UPDATE owner_kakao_links
   SET status='LINKED', channel_user_key=:key, linked_at=now(), code_sha=NULL
 WHERE code_sha=:sha AND deleted=false AND status='PENDING'
   AND code_expires_at > now() AND code_attempts < :max
RETURNING user_id;

조회 후 갱신으로 나누면 같은 코드가 두 번 먹는다(승인 흐름이 같은 이유로 한 문장이다).

실패는 전부 같은 에러다(KAKAO_LINK_CODE_INVALID). "없는 코드"·"만료"·"시도 초과" 를 구분해 답하면 6자리 코드의 유효성을 외부에서 탐색할 수 있다.

코드는 웹훅에서만 소비된다

redeem() 은 공개 라우터에 붙어 있지 않다. 시크릿 검증을 통과한 웹훅 안에서만 불린다 — 검증 없는 공개 소비 경로가 있으면 누구나 6자리를 대입해 남의 계정에 자기 카톡을 붙인다.

API

메서드/경로 역할
GET /v1/agent/kakao/link 연결 상태. ★ 코드 평문은 주지 않는다
POST /v1/agent/kakao/link/code 일회용 코드 발급. 평문은 이 응답에서 한 번만
POST /v1/agent/kakao/link/disconnect 해제. 행은 REVOKED 로 남긴다

셋 다 Cache-Control: no-store · Referrer-Policy: no-referrer · X-Robots-Tag: noindex 다.

설정

KAKAO_CHANNEL_PUBLIC_ID=      # 비면 연결 기능이 꺼진다(카드는 보이고 버튼만 죽는다)
KAKAO_LINK_CODE_TTL_MIN=10
KAKAO_LINK_MAX_ATTEMPTS=5

config/agent_config.py 는 social_config.py 와 일부러 갈랐다. SNS 게재는 되돌릴 수 없는 대외 발화이고, 에이전트는 사장님이 자기 사이트를 고치는 창구다. 한 파일에 섞이면 "이 값이 무엇을 여는가" 가 흐려진다.


2단계 — 도구 · 런타임 · 빌더 채팅창

/sites 화면 오른쪽 아래 [말로 고치기] 를 누르면 대화창이 열린다. 카카오 심사 없이 에이전트 전체가 여기서 검증된다.

겹

router/v1/agent/chat.py          빌더 화면 입구
router/v1/social/kakao_bot.py    (4단계) 카톡 입구 — 같은 runtime.chat() 을 부른다
        ↓
services/agent/runtime.py        발화 → 도구 선택 → 실행 → 응답. ★ 채널을 모른다
services/agent/tools.py          레지스트리 — 할 수 있는 일의 전부 + 등급
        ↓
services/fact_service.py · site_service.py      ★ 게이트가 사는 곳

services/prompts/agent.py 가 "무엇을 묻는가" 를 갖는다(LLM 네 겹 규약, services/llm/__init__.py).

도구와 등급

등급 도구 대화에서
READ get_site_status · list_facts · list_sections · list_photos 바로 답한다
REVERSIBLE set_fact · toggle_section · move_section · hide_photo · set_primary_photo 실행하고 알린다
SEMI publish 실행 전에 한 번 묻는다

페이지 구성 (2026-09-28)

"날씨 빼줘" · "사진 갤러리 맨 위로" 처럼 화면 구성을 바꾼다. 구성은 sites.theme.sections 배열 하나이고, 배열 순서가 곧 발행본의 섹션 순서다.

★ 목록은 site_payload._sections 를 그대로 쓴다 — 발행본이 쓰는 바로 그 함수다. 표를 따로 만들면 에디터·발행본·대화 셋이 갈라지고, 사장님은 "껐는데 나온다" 를 겪는다. 저장값이 없어도 업종 기본이 서므로, 디자인을 한 번도 안 만진 사업장에서도 바로 통한다.

★ 잠긴 섹션(히어로·기본 정보·오시는 길)은 끌 수 없다. SEO·필수 마크업 때문에 잠긴 것이고, _sections 가 어차피 켜서 내보낸다 — 끌 수 있게 두면 화면만 거짓말한다.

★ 이름이 둘 이상 걸리면 고르지 않는다. 추측으로 고르면 엉뚱한 부분을 끄고, 사장님은 발행하고 나서야 안다. 티오더가 "유사 메뉴 2개 이상이면 후보 제시" 로 푼 것과 같은 문제다.

★ sections 만 갈아끼운다. theme 을 통째로 새로 쓰면 사장님이 고른 색·서체가 말없이 사라진다.

옮기기 · 숨기기 (2026-09-29)

말 move_section 인자 세는 기준
"갤러리 맨 위로 / 맨 아래로" where=맨 위 · 맨 아래 배열
"갤러리를 소개 앞으로 / 다음으로" where=앞 · 뒤, to=소개 배열
"갤러리 한 칸 위로 / 두 칸 아래로" where=위로 · 아래로, count=1 · 두 보이는 순서
"날씨 세 번째로" where=번째, count=3 보이는 순서
"소개랑 갤러리 자리 바꿔줘" where=바꾸기, to=사진 갤러리 배열

where 가 비면 예전 표기로 읽는다(to 에 '맨 위' · '맨 아래' · 그 뒤에 올 이름). ★ where 가 왔는데 못 알아들으면 예전 표기로 넘기지 않고 되묻는다 — to 만 보고 '다음으로' 옮기면 "소개 앞쪽으로" 가 소개 뒤로 간다. 섹션을 여럿 적을 때 구분은 쉼표뿐이다(· 는 이름에 들어 있다).

★ 히어로·SNS 게시글은 옮기지 않는다(tools.PINNED). 발행본(site/src/pages/HomePage.tsx)이 배열 순서와 상관없이 히어로를 늘 맨 위에, SNS 를 늘 맨 아래에 그린다 — 옮기게 두면 "옮겼습니다" 라고 말하는데 화면은 그대로다. 그 둘을 기준으로 삼는 것도 같다: 히어로 다음은 맨 위, SNS 앞은 맨 아래로 읽고, 히어로 앞 · SNS 뒤 · 그 둘과 자리 바꾸기는 거절한다. 기본 정보·오시는 길은 잠겼어도 순서대로 그려지므로 옮길 수 있다. 프롬프트에도 [항상 맨 위] · [항상 맨 아래] 로 싣는다.

★ '한 칸' · 'N번째' 는 보이는 순서로 센다(켜진 것, 히어로·SNS 제외). 꺼진 부분은 화면에 없어서, 배열로 세면 꺼진 부분과 자리만 바꾸고 화면은 그대로인 이동이 생긴다. 그래서 꺼진 부분은 칸으로 옮기지 않고(켠 뒤 말하거나 '소개 다음으로' 처럼), 없는 순번(1~보이는 수 밖)은 추측하지 않고 거절한다. list_sections 의 번호가 이 순서다 — 목록에서 본 번호로 말했는데 다른 자리로 가면 고장난 줄 안다. 이미 그 자리면 Unchanged 다(재발행을 권하지 않는다).

★ 숨기기는 끄기다 — 지우지 않는다. 에디터에도 빼는 기능이 없고, _sections 가 저장값에 없는 기본 섹션을 켜서 끝에 다시 붙인다. toggle_section 은 name 에 쉼표로 여럿을 받는다("사진 갤러리, 날씨"). ★ 여럿 중 하나라도 못 찾거나 끌 수 없으면 아무것도 바꾸지 않는다 — 일부만 끄면 사장님은 무엇이 꺼졌는지 다시 확인해야 한다. 하나의 요청이라 상한(5개)도 하나로 센다. list_sections 는 꺼진 것을 따로 모아 보여 주고, only=꺼진 이면 그것만 답한다("숨긴 거 뭐 있어").

⚠️ 이용 후기 · 엽서 쓰기는 섹션 목록에 없다 — 발행본이 늘 그린다. "후기 빼줘" 는 "못 찾았어요" 로 끝난다.

사진 (2026-09-28)

"객실 사진 내려줘" · "대표 사진 수영장으로 바꿔줘". 지목은 Vision 이 만든 라벨·alt 로 한다.

★ 대표 사진은 별도 칸이 아니라 목록의 첫 장이다(site_payload.primary_media). 그래서 '대표로 지정' 은 sort_order 를 가장 작게 내리는 일이다 — 칸을 따로 두면 규칙이 둘이 되고, 검색 결과에 뜨는 그림과 화면 첫 장이 갈린다.

★ 객실·메뉴 전용 사진(unit_id 있음)은 대표가 될 수 없다. primary_media 가 건너뛰므로 지정하게 두면 화면만 거짓말한다.

★ 내려도 지우지 않는다(REJECTED). origin_url·source_type 이 남아 있어야 재게시 권리(DECISIONS 1-2) 결론이 났을 때 무엇을 실었는지 되짚을 수 있고, 잘못 내렸을 때 되돌릴 수도 있다.

★★ 업로드·교체 도구는 만들지 않았다. 이미지 재게시 권리가 미결이라 저장 경로를 일부러 안 만들어 둔 것이고(DECISIONS 5-3), 도구가 생기면 그 결정을 코드가 먼저 풀어 버린다. 테스트가 레지스트리에 upload·replace 가 없는지 실제로 검사한다.

★ 대표·목록은 '나가는 사진' 기준이다(2026-09-29). 내린 사진(publishable 아님)의 순서만 당기면 "바꿨습니다" 라고 말하는데 발행본의 대표는 그대로다. 그래서 내린 사진은 대표로 지정하지 않고, 이미 내린 사진을 또 내리라면 "이미 안 나가고 있어요" 로 답한다. 프롬프트에는 나가는 사진만, 대표를 맨 앞에 싣는다. 이름이 정확히 맞는 한 장이 있으면 부분 일치가 여럿이어도 그걸 고른다.

★ 서버 엔드포인트(POST .../media/{id}/hide · /primary)도 함께 열었다 — 에이전트 전용 뒷문을 만들면 빌더 화면이 그 기능을 못 쓰고, 나중에 붙일 때 로직이 두 벌이 된다.

★ 템플릿·색 변경은 아직 없다. 목록이 프론트(frontend/src/data/industryData.ts)에 있고 templatesFor() 가 색·look·기본 섹션·배리에이션을 함께 계산한다. 백엔드가 template_id 만 바꾸면 색은 옛것이 남아 "레이아웃은 새것, 색은 옛것" 이 된다 — 조용히 틀리는 종류다. 하려면 그 레지스트리를 공유 단일 출처로 옮기는 작업이 먼저다.

★ 등급은 레지스트리가 못 박는다. 모델이 정하게 두면 프롬프트에 끼어든 한 줄이 확인 절차를 건너뛴다. 그래서 응답 스키마에 등급 칸 자체가 없고, 도구 목록에도 등급을 싣지 않는다.

★ 결과 문구는 도구가 만든다. LLM 이 쓰게 두면 하지 않은 일을 했다고 말할 수 있고, 사장님에게는 그 말이 사실로 보인다. 모델 문장은 '되묻기' 에만 쓴다.

★ 값을 고치면 재발행 안내를 함께 낸다. fact 는 바뀌어도 사이트는 안 바뀐다 — 이 한 줄이 빠지면 사장님은 반영된 줄 알고 확인하러 갔다가 옛 값을 보고 "고장났네" 가 된다.

★ 모호하면 실행하지 않고 되묻는다. 티오더가 "유사한 메뉴가 2개 이상이면 후보 목록을 제시" 로 푼 문제와 같다 — 추측으로 고르면 사장님이 그걸 못 알아채고 넘어간다.

값 형식 (2026-09-29)

저장 형식은 수집 어댑터와 같다 — bool true/false · time HH:MM · number 숫자만. 렌더러(shared/src/lib/facts.ts factBool)는 'true' 만 참으로 읽어서, "가능" 으로 저장하면 화면에는 "가능" 이 뜨는데 구조화 데이터는 거짓이 된다 — 빌드도 성공하는 조용한 틀림이다.

형식 받는 말 → 저장값 되묻는 경우
bool 가능·돼요·있음 → true, 불가·안 돼요·없음 → false "소형견만" 처럼 가능·불가가 아닌 말
time 15:00 · 오후/낮 3시 · 15시 30분 · 3시 반 → HH:MM, 밤 12시 → 00:00 "3시"(오전·오후 모름) · 25:00
number 2만원 → 20000 · 2만 5천원 → 25000 · 만원 → 10000 · 20,000원 · 무료 → 0 "문의" · "만 오천원" 처럼 숫자가 아닌 말

두 겹이다 — 프롬프트가 항목마다 형식을 싣고(key: 이름 (형식)), 도구가 다시 맞춘다 (tools._normalize). ★ 모델만 믿지 않는다: 스키마를 어기고 true 를 불리언으로, 인자를 배열로 보낼 때도 있다(_arg · runtime._args 가 받는다). 사장님께 알리는 문장은 저장값이 아니라 발행본의 말로 한다 — "반려동물 동반 을(를) 가능 로 바꿨습니다".

⚠️ 이 검증은 대화 경로에만 있다. fact_service.upsert_fact 는 형식을 보지 않는다(빌더·수집기 공용).

★ 켤지 끌지 모르면 끄지 않는다. 스키마가 모든 인자를 필수로 받아 모델이 enabled 를 "" 로 채울 수 있다. 예전에는 모르는 말을 '끄기' 로 읽어서 "후기 다시 보여줘" 가 후기를 껐다.

한 발화에 여러 가지 (2026-09-28)

"체크인 3시로 바꾸고 후기 섹션도 빼줘" 처럼 한 번에 시킨다. 응답 스키마가 actions 배열이고 런타임이 시킨 순서대로 실행한다(MAX_ACTIONS = 5).

READ · REVERSIBLE   실행하고 결과를 모은다
SEMI(publish)       ★ 거기서 멈춘다 — 앞서 한 일을 함께 말하고 확인을 받는다
실패                ★ 거기서 멈춘다 — 앞의 것은 되돌리지 않는다

★ 확인이 필요한 행위를 다른 일에 묻어 실행하지 않는다. publish 가 섞여 오면 그 앞까지만 하고 확인을 받는다 — 묻어서 실행하면 확인의 의미가 없다.

★ 부분 실패를 되돌리지 않는다(2026-09-28 결정). 되돌리는 것도 사장님이 시키지 않은 변경이다. 대신 무엇이 됐고 무엇이 안 됐는지 그대로 말한다 — 뭉뚱그리면 전부 된 줄 안다.

체크인 시간을 15:00로 바꿨습니다.
어느 부분을 말씀하시는지 못 찾았어요… — 여기서 멈췄습니다.
사이트에 반영하려면 다시 발행해야 해요 — 지금 할까요?

★ 재발행 안내는 한 번만 붙는다(Tool.republish 플래그 → 런타임이 조립). 도구마다 문장에 박아 두면 셋을 고쳤을 때 같은 말이 세 번 나온다.

★ 상한 5개. 무한정 허용하면 "다 지워줘" 한 마디에 연쇄로 실행된다.

못 한 것·남은 것·겹친 것 (2026-09-29)

★ 말없이 빠뜨리지 않는다. 되는 것만 하고 입을 다물면 사장님은 전부 된 줄 안다.

경우 답
도구로 할 수 없는 요청이 섞임 ("…전화번호도 바꿔줘") '전화번호 변경' 은(는) 대화로는 아직 할 수 없어요.
모델이 지어낸 도구 알아듣지 못한 요청 1가지는 하지 않았어요.
실패·발행에서 멈춤 — 그 뒤의 요청 소개 옮기기, 체크아웃 시간 변경 은(는) 아직 하지 않았어요.

→ 응답 스키마의 skipped 칸은 이름만 받는다("전화번호 변경"). 문장은 런타임이 틀에 끼워 만든다 — 문장을 받으면 모델이 "했습니다" 라고 쓸 자리가 생긴다. 이 칸이 생기기 전에는 message 가 actions 가 있으면 버려져서, 모델이 "전화번호는 못 해요" 라고 써도 사장님께 닿지 않았다. → 남은 요청의 이름도 도구가 만든다(Tool.title · Tool.describe → tools.describe_action). → ★ 발행에서 멈출 때 묻는 말은 맨 끝에 선다. 그 뒤에 다른 말이 붙으면 [네, 해주세요] 가 무엇에 대한 답인지 흐려진다. 확인을 눌러도 발행 하나만 돈다 — 그래서 남은 것을 확인 전에 알린다.

★ 같은 대상을 두 번 시키면 마지막 하나만 한다("체크인 3시… 아니 4시로"). 둘 다 하면 문구에 두 값이 함께 서서 어느 쪽이 남았는지 모른다. 같은 대상인지는 Tool.target 이 정한다(set_fact 는 key, 켜기·끄기와 사진 내리기는 name, 대표 사진·발행은 하나뿐). 자리는 마지막 것의 자리이고, 상한을 세기 전에 합친다 — 고쳐 말한 것까지 세면 할 수 있는 일이 잘린다. ★ 옮기기는 합치지 않는다(인자까지 똑같을 때만). 차례가 뜻이다 — "날씨 맨 위로, 그리고 한 칸 아래로" 를 마지막 하나로 합치면 두 번째 자리가 아니라 원래 자리에서 한 칸 아래가 된다.

★ 바뀐 것이 없으면 재발행을 권하지 않는다. "이미 켜져 있어요" 에 "다시 발행해야 해요" 가 붙으면 무언가 바뀐 줄 안다. 도구가 Unchanged 로 돌려주면 런타임은 done=False 로 두고, 카톡은 발행 대기를 걸지 않는다.

★ 지금 고칠 수 있는 가게는 하나다. 프롬프트에 그 가게만 실린다. 카톡에서 다른 내 가게 이름이 발화에 나오면 모델을 부르기 전에 끊고 고르게 한다(channel._other_named) — 그대로 넘기면 지금 가게가 바뀌고 사장님은 다른 가게가 바뀐 줄 안다. 기억한 가게(current_place_id)가 목록에 없으면 비우고 목록을 보여 준다.

확인(SEMI) 한 바퀴

  1. 발화 → 런타임이 publish 를 고른다 → 실행하지 않고 needs_confirm=true + 확인 문구
  2. 화면이 [네, 해주세요] 를 띄운다
  3. 누르면 {confirm:{tool,args}} 로 다시 POST → LLM 을 부르지 않고 그 도구를 실행

★ 서버는 돌아온 값을 믿지 않는다. 도구 이름은 레지스트리에서 다시 찾고, 인자는 도구가 다시 검증한다. 확인 절차가 오히려 검증을 건너뛰는 구멍이 되면 안 된다. READ 등급은 확인 경로로 들어올 수 없다(AGENT_UNKNOWN_TOOL).

API

메서드/경로 역할
GET /v1/agent/status 대화창을 열 수 있는지(LLM 키 유무)
POST /v1/agent/chat/{place_id} {message} 또는 {confirm:{tool,args}}

소유자 범위는 다른 엔드포인트와 같다 — 남의 place_id 는 없는 것과 똑같이 PLACE_NOT_FOUND 다. 대화창이 소유자 스코프를 우회하는 유일한 입구가 되면 안 된다.

다음 단계

내용 심사
3 도구를 더 연다 — 사진 내리기 · 섹션 켜고 끄기 · 검색 노출 조회 없음
4 카카오 채널 웹훅을 입구로 추가(서명 검증 + redeem 연결) 채널 + 챗봇

★ 도구를 늘릴 때도 반드시 services/* 를 통과한다. crud 를 직접 부르면 업종 스키마 검증·출처 필수·정정본 보호가 아무 증상 없이 사라진다. collect_service.store_facts 가 크롤러에 걸어 둔 문과 같은 문이고, tests/test_agent_runtime.py 가 소스에서 그 호출이 없는지 실제로 검사한다.


4단계 — 카카오 채널 웹훅

router/v1/agent/kakao_bot.py   시크릿 검증 · 카카오 형식 ↔ 우리 모양   ← 카카오를 아는 유일한 파일
services/agent/channel.py      신원 · 가게 고르기 · 확인 이어받기      ← 카카오를 모른다
services/agent/runtime.py      그대로 — 채널을 모른다

★★ 인증 — 오픈빌더는 서명을 주지 않는다

URL 만 알면 누구나 이 엔드포인트를 때릴 수 있고, userRequest.user.id 를 아무 값이나 넣으면 그 사장님 행세를 한다. 신원 연결이 통째로 무의미해지는 자리다.

겹 방법
1 공유 시크릿 — 헤더 X-Agent-Secret (hmac.compare_digest)
2 KAKAO_BOT_ID 대조 (시크릿이 아니라 오발송을 거르는 용도, 비워도 됨)
3 헤더를 못 넣을 때만 경로 시크릿 /webhook/{secret} — 최후 수단, 경로는 로그에 남는다

★ KAKAO_WEBHOOK_SECRET 이 비면 엔드포인트가 404 다. 401 로 답하면 "여기 뭔가 있다" 를 알려 준다. 반쯤 열린 상태를 만들지 않는 것은 Threads 연결과 같은 규칙이다.

빌더 화면과 다른 것 셋

빌더 화면 카카오톡
신원 로그인 토큰 연결된 발화자 키 → user_id (★ 토큰을 발급하지 않는다)
대상 place_id 가 URL 에 대화에서 고르고 current_place_id 에 기억
확인 프론트가 {confirm} 을 되돌려 줌 서버가 무엇을 물었는지 들고 있는다

★ 연결되자마자 홈페이지 목록을 보여준다. 연결만 알리고 끝내면 사장님은 어느 홈페이지를 다루는 대화인지 모른 채 말을 걸게 된다. 목록에는 발행 여부를 같이 적는다 — 안 그러면 고친 것이 손님에게 보이는 줄 안다.

★ 가게가 여럿이면 바로가기 버튼으로 고르게 한다. 이름을 외워 치게 하지 않는다. 임의로 첫 가게를 고르지도 않는다 — 사장님은 엉뚱한 가게를 고쳐 놓고도 모른다. "목록"·"가게 바꿔줘" 같은 말로 언제든 돌아와 바꿀 수 있고, 이 경로는 LLM 을 부르지 않는다 (대화가 막혔을 때 처음 찾는 길이라 늘 통해야 하고, 목록 보기에 돈을 쓸 이유도 없다).

★ pending_expires_at(3분)이 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다. 다른 말을 하면 그 말이 우선이고, 묵은 확인은 그 자리에서 치운다.

★ 바로가기 라벨과 '예' 로 읽는 말이 같아야 한다(CONFIRM_LABEL 등 상수). 어긋나면 눌러도 안 먹고, 사장님은 버튼이 고장난 줄 안다.

발행 요청 응답 (2026-09-29)

발행 작업이 접수되면 "발행을 시작했습니다. 완료 후 아래 주소에서 확인해 주세요." 와 해당 사이트의 URL을 함께 응답한다. 주소는 site_payload.publish_url로 구해 발행 주소와 같은 규칙을 쓴다. 완료를 기다리거나 별도 완료 알림을 보내지 않는다 — 이 응답은 접수 안내다.

5초 벽 — 콜백으로 넘는다

오픈빌더의 스킬 타임아웃은 5초다. 넘기면 카카오가 끊어 말없이 실패하는 봇이 된다.

★ 실측(2026-09-22): 실제 프롬프트는 4초를 넘겼다. 개발 중 잰 1.3~2.4초는 항목 두 개짜리 장난감 프롬프트였고, 진짜는 업종 필드 43개 + fact 수십 개가 실린다. 작은 표본으로 잰 수치를 상한 근거로 삼으면 이렇게 틀린다.

→ 오픈빌더 스킬 설정에서 콜백 사용을 켜면 요청에 userRequest.callbackUrl 이 실려 온다.

카카오 → 우리   발화 + callbackUrl
우리 → 카카오   {"version":"2.0","useCallback":true,"data":{"text":"확인하고 있어요…"}}   (즉답)
              … 백그라운드에서 답을 만든다 (상한 45초)
우리 → 카카오   POST callbackUrl  {"version":"2.0","template":{…}}                      (완성분)

★ 콜백 주소는 1분 · 1회만 유효하다. 전송에 실패해도 재시도하지 않는다 — 두 번째 POST 는 어차피 거절되고, 사장님에게는 이미 "확인하고 있어요" 가 가 있다.

★ 콜백이 꺼져 있으면 예전처럼 동기로 답하되 DEADLINE_SEC = 4.5 로 끊는다. 무거운 잡(BUILD)은 큐에 넣고 즉답하는 구조라 어느 쪽에서도 걸리지 않는다.

★ 어떤 실패도 HTTP 200 + 안내 문구로 답한다. 메신저에서는 500 도 침묵으로 보인다.

설정

KAKAO_WEBHOOK_SECRET=     # 비면 웹훅이 404. python -c "import secrets; print(secrets.token_urlsafe(32))"
KAKAO_BOT_ID=             # 선택
KAKAO_CHANNEL_PUBLIC_ID=  # 채워야 연결 카드가 뜬다(채널 검색용 아이디, `_` 로 시작)

오픈빌더에 등록할 주소

https://<발행호스트>/v1/agent/kakao/webhook

★ 스킬 설정에서 커스텀 헤더를 넣을 수 있으면 X-Agent-Secret 을 쓰고, 못 넣으면 /v1/agent/kakao/webhook/<시크릿> 을 쓴다.

★ 채널 '채팅' 과 '챗봇(오픈빌더 스킬)' 은 다른 기능이다. 채팅만 켜면 발화가 우리에게 오지 않는다 — 웹훅이 붙는 쪽은 챗봇이고, 봇을 만들어 채널에 연결해야 한다.


5단계 — 챗봇이 먼저 보내기 (Event API) — 승인 알림을 카톡으로

메일로 가던 미니블로그 승인 알림을 연결된 카카오톡으로도 보낸다(2026-09-29 결정).

결정

결정
발송 카톡과 메일 둘 다. 카톡이 연결돼 있어도 메일을 같이 보낸다 — 카톡 발송은 채널 친구가 아니거나 차단했으면 실패하므로 메일이 누락을 막는다
승인 방식 메시지의 [승인] [수정] 인라인 버튼 + 누른 사람이 연결된 본인인지·그 글이 본인 가게 것인지 서버가 확인. 링크가 없어 메신저 미리보기가 먼저 열어 승인되는 문제가 처음부터 없다. 승인은 기존 PostService.approve_by_owner 를 그대로 탄다
진행 1단계(이 절): 클라이언트 + 테스트 발송으로 규격 확인. 2단계: 버튼 응답·승인 처리·발송 연결

1단계에서 만든 것

services/external/kakao_event.py       Event API 클라이언트 — 카카오 계약은 여기 한 곳
scripts/kakao_event_send_test.py       테스트 발송(실제 카톡으로 한 건)
config/agent_config.py                 KAKAO_BOT_ID(필수화) · KAKAO_BOT_REST_API_KEY · KAKAO_EVENT_DEV
POST https://bot-api.kakao.com/v2/bots/{botId}/talk        (개발 채널이면 botId 뒤에 "!")
Authorization: KakaoAK {REST API 키}
{"event":{"name":"…","data":{…}}, "user":[{"type":"botUserKey","id":"…"}], "params":{…}}
→ {"taskId":"…","status":"SUCCESS", …}
  • event.data 는 말풍선에서 {{#current.event.data.<이름>}} 으로, params 는 스킬 서버에 userRequest.params 로 전달된다 — 2단계에서 글 ID 를 params 에 실어 보낸다.
  • 수신자는 botUserKey 다. 웹훅의 userRequest.user.id 와 같은 값이라 owner_kakao_links.channel_user_key 를 그대로 쓴다.
  • ★ KAKAO_BOT_REST_API_KEY 는 기존 KAKAO_REST_API_KEY(카카오 로컬 API, 주소 검색)와 일부러 갈랐다. Event API 는 채널을 연결한 비즈니스 인증 앱의 키를 써야 해서 앱이 다를 수 있고, 같은 이름이면 한쪽을 채울 때 다른 쪽이 조용히 켜지거나 틀린 키로 나간다.
  • ★ KAKAO_EVENT_DEV=1 로 개발 채널을 가린다. KAKAO_BOT_ID 자체에 ! 를 붙여 쓰지 않는다 — 웹훅이 그 값으로 요청의 bot.id 를 대조한다.
  • 예외 문구(str(ex))에는 키·발화자 ID·응답 원문이 없다. 진단용 원문은 KakaoEventError.detail.

콘솔에서 먼저 끝내야 하는 것 (코드로 못 한다)

  1. 카카오 디벨로퍼스: 앱 비즈니스 정보 심사 승인 + 카카오톡 채널 연결(비즈니스 인증 채널과 앱)
  2. 월렛 생성·연결 — Event API 는 발송 성공 건당 15원(VAT 별도)
  3. 오픈빌더: 이벤트 정의(예: post_approval) → 이벤트 블록의 말풍선에 {{#current.event.data.text}} → 배포 (배포 전에는 발송되지 않는다)
  4. .env: KAKAO_BOT_ID · KAKAO_BOT_REST_API_KEY (개발 채널이면 KAKAO_EVENT_DEV=1)

한계 (카카오 쪽 제약)

  • 사용자 식별값은 사용자가 채널에 처음 말을 건 뒤에야 채번된다 — 연결 코드를 보낸 사장님만 받을 수 있다.
  • 채널 친구가 아니거나 차단했으면 전송은 실패한다 → 메일이 같이 나가는 이유.

테스트 발송

# botUserKey 는 연결된 사장님의 값이다
#   SELECT channel_user_key FROM owner_kakao_links WHERE status='LINKED' AND deleted=false;
cd solution/backend && .venv/bin/python scripts/kakao_event_send_test.py --key <botUserKey>
# 서버(도커)에서는:  docker compose exec solution-backend python scripts/kakao_event_send_test.py --key <botUserKey>

성공하면 카톡에 이벤트 블록의 말풍선이 뜬다. 요청은 성공인데 안 오면 이벤트 블록 연결 · 배포 · 채널 친구 여부 순서로 본다.

2-1 진행 — 발송과 메시지 그리기 (2026-09-29)

blog_jobs._send_one     메일 + 카톡 Event API(params: post_id, edit_token) — 하나라도 나가면 SENT
오픈빌더 이벤트 블록     스킬 데이터 응답 → 우리 웹훅(POST /v1/agent/kakao/webhook)
kakao_bot._handle       userRequest.params.post_id 가 있으면 승인 알림 요청으로 처리
channel.approval_notice 연결된 본인 가게의 글일 때만 본문 + [수정하기] 링크
  • 스위치는 KAKAO_APPROVAL_PUSH_ENABLED(기본 0), 이벤트 이름은 KAKAO_APPROVAL_EVENT_NAME(기본 post_approval).
  • ★ 글 ID 는 믿지 않는다. 발화자 키 → 연결된 사장님 → 그 글이 그 사장님 가게 것인지를 서버가 다시 본다. 연결 안 된 발화자·남의 글·이미 처리한 글·기한(그날 자정 KST) 지난 글은 같은 안내로 답한다 — 구분해 주면 글 ID 를 탐색할 수 있다.
  • [수정하기] 는 메일의 '고쳐서 올리려면' 과 같은 일회용 코드다(/v1/site/post/edit?t=). 평문은 발송 시점에만 알아서 Event API params.edit_token 으로 넘긴다 — 어느 쪽이든 먼저 누른 쪽이 쓴다. 코드는 카카오 서버를 지나가므로 로그에는 params 의 키만 남기고 값은 남기지 않는다.
  • 메일이 나갔으면 카톡보다 먼저 SENT 로 표시한다(웹훅이 곧바로 글을 읽는다). 카톡만 나가는 경우는 발송 성공 뒤에 표시하고, 웹훅은 REVIEWED 글도 읽는다.
  • 링크 버튼은 textCard 로 본문(simpleText)과 따로 둔다 — 카드 설명 길이 제한에 글 문구가 걸리지 않게.

콘솔에서 바꿔야 하는 것

  1. post_approval 이벤트 블록의 말풍선을 고정 문구({{#current.event.data.text}})에서 스킬 데이터 응답으로 바꾸고 우리 스킬을 연결한다(이벤트 블록에서도 스킬 서버를 쓸 수 있다).
  2. 이벤트 블록에는 필수 파라미터를 설정하지 않는다 — 설정하면 메시지가 발송되지 않는다.
  3. 배포.

2-2 — [승인] 버튼 (2026-09-29)

알림 카드 [승인] (action: block, blockId=KAKAO_APPROVE_BLOCK_ID, extra={kind:approve, post_id})
 → 그 블록의 스킬 요청 body.action.clientExtra 로 extra 도착
 → kakao_bot._approve_click → channel.approve_post
 → 발화자 키 → 사장님 → 그 가게의 미처리·기한 전 글인지 재확인 → PostService.approve_by_owner
 → "올렸습니다" + [사이트 보기](#blog)
  • ★ 승인 권한은 링크가 아니라 연결된 계정이다. extra 의 글 ID 는 믿지 않는다. 연결 안 된 발화자·남의 글·이미 올린 글·기한 지난 글은 아무것도 승인하지 않고 같은 안내로 답한다. 이미 올린 글을 또 눌러도 같은 관문에서 걸려 두 번 올라가지 않는다.
  • ★ KAKAO_APPROVE_BLOCK_ID 가 비면 [승인] 버튼을 그리지 않는다 — 콘솔 준비 전에 눌러도 안 되는 버튼을 사장님 카톡에 보내지 않는다.
  • ★ 승인 처리는 LLM 을 부르지 않는다 — 결정적이어야 하고 유료 호출도 필요 없다.
  • ★ 5초를 넘겨도 승인 작업을 취소하지 않는다(asyncio.shield). 승인은 상태 변경 → 재발행 잡 → 쓰레드 공유로 여러 번 커밋해서, 중간에 끊기면 승인만 되고 재발행이 안 걸린 글이 남는다. 응답만 "승인하고 있어요" 로 먼저 돌려준다.
  • 로그에는 extra 의 키만 남긴다(승인 클릭 — extra=[...]) — 첫 실클릭에서 본문 모양을 확인한다.

콘솔

블록 이벤트 발화 필수 파라미터 봇 응답
미니 블로그 알림 post_approval 없음 없음 스킬데이터(web4ai-agent)
미니 블로그 승인 없음 없음 없음 스킬데이터(web4ai-agent)

미니 블로그 승인 블록 ID(주소의 /intent/<ID>)를 KAKAO_APPROVE_BLOCK_ID 에 넣는다.