말로 고친 값이 형식 검증 없이 저장됐다 — "반려동물 이제 돼요" 가 "가능" 으로 들어가면 화면엔 "가능" 이 뜨는데 factBool 은 'true' 만 참으로 읽어 구조화 데이터가 거짓이 된다. 여러 요청을 한 번에 받을 때도 할 수 없는 것·멈춘 뒤의 것이 말없이 사라져 사장님은 전부 된 줄 알았다(message 는 actions 가 있으면 버려졌다). - tools: set_fact 값을 스키마 형식으로 맞춘다(bool true/false · time HH:MM · number 숫자) — "3시" 처럼 오전·오후를 모르면 저장하지 않고 되묻는다. 알림 문구는 가능·불가로 말한다 - tools: enabled 를 모르면 끄지 않고 되묻는다 — "" 를 끄기로 읽어 "다시 보여줘" 가 섹션을 껐다 - tools: 대표 지정·사진 목록·프롬프트를 '나가는 사진' 기준으로 — 내린 사진을 대표로 지정하고 "바꿨습니다" 라고 하던 것. 정확히 맞는 이름을 부분 일치보다 먼저 고른다 - tools: Unchanged 표시 — "이미 켜져 있어요" 에 재발행 안내·카톡 발행 대기가 붙던 것 - tools/runtime: 인자가 문자열·dict 가 아니어도 죽지 않는다(모델의 스키마 위반) - runtime: 같은 대상은 마지막 하나로 합친다(Tool.target, 상한 세기 전). 멈춘 뒤 남은 요청과 지어낸 도구를 코드가 만든 이름으로 알린다. 발행 확인 문구는 맨 끝에 선다 - prompts: 항목마다 형식을 싣고 skipped(할 수 없는 요청의 이름) 칸 추가 — 문장은 런타임이 만든다. 다른 가게 이야기면 되묻는 규칙 - channel: 발화에 다른 내 가게 이름이 나오면 모델을 부르기 전에 고르게 한다. 기억한 가게가 목록에 없으면 비우고 목록으로. 확인 경로의 AgentError 를 사장님 말로 옮긴다 - docs/AGENT.md: 값 형식 · 못 한 것·남은 것·겹친 것 · 나가는 사진 기준 테스트 48건 추가, 에이전트·카카오 153 passed. 전체 932 passed / 47 failed — 실패 47건은 변경 전과 동일(gemini·openai 키 미설정, search_console KeyError, weather_notes) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
403 lines
24 KiB
Markdown
403 lines
24 KiB
Markdown
# 사장님 에이전트 — 신원 연결 · 도구 · 런타임
|
|
|
|
사장님이 말로 사이트를 운영하는 것이 목표다 — 내용 고치기, 사진 내리기, 발행, 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` 를 줄 근거가 없다 — **연결 절차가 없으면 채널 진입점만 소유자 범위 밖에
|
|
놓이고, 채널에 말을 건 아무나가 남의 가게를 고친다.**
|
|
|
|
## 절차 — 사장님은 두 번 누른다
|
|
|
|
1. `/sites` **내 사이트** 화면의 `카카오톡으로 관리 · 채널 연결` 카드 → **[카카오톡 연결]**
|
|
2. 화면에 뜬 6자리 코드를 카카오톡 채널에 보낸다
|
|
|
|
★ **연결 버튼을 사업장 화면에 두지 않는다.** 연결은 `user` 단위인데 버튼이 사업장 안에 있으면
|
|
사장님은 업장마다 연결해야 하는 줄 안다(`SocialConnectionCard` 가 같은 이유로 거기 있다).
|
|
|
|
★ `KAKAO_CHANNEL_PUBLIC_ID` 가 비면 **카드는 그리되 버튼이 죽는다.** 어디에 코드를 칠지
|
|
말해 줄 수 없는데 코드만 발급하면 사장님에게는 고장난 화면이다. 숨기지는 않는다 — 숨기면
|
|
기능이 없는 것처럼 보인다(2026-09-14 Threads 카드에서 실제로 겪었다).
|
|
|
|
## 표 — `owner_kakao_links` (마이그레이션 0021)
|
|
|
|
`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 가 보장한다
|
|
|
|
```sql
|
|
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-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` | **"3시"(오전·오후 모름)** · 25:00 |
|
|
| number | 2만원 → `20000` · 2만 5천원 → `25000` · 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` 등 상수). 어긋나면
|
|
눌러도 안 먹고, 사장님은 버튼이 고장난 줄 안다.
|
|
|
|
## 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/<시크릿>` 을 쓴다.
|
|
|
|
★ **채널 '채팅' 과 '챗봇(오픈빌더 스킬)' 은 다른 기능이다.** 채팅만 켜면 발화가 우리에게
|
|
오지 않는다 — 웹훅이 붙는 쪽은 챗봇이고, 봇을 만들어 채널에 연결해야 한다.
|