o2o-site-AEO/docs/AGENT.md
hbyang a553e41197 [chore] solution/backend,frontend: 에이전트 화면 보류 — 설정으로 닫고 코드는 남긴다
카카오톡 채널 개설이 법인폰 본인인증에 걸려 보류됐다. 채널이 없으면 대화창은
사장님에게 **어디에도 닿지 않는 입구**이고, 열려 있으면 "되는 기능" 으로 오해한다.

- config/agent_config: AGENT_CHAT_ENABLED 신설(기본 0)
- runtime.is_configured(): 스위치와 LLM 키를 둘 다 본다 — 화면을 우회해 API 를
  직접 불러도 AGENT_NOT_CONFIGURED 다
- AgentChatDock · KakaoChannelCard: 조건 미충족이면 통째로 감춘다(return null).
  연결 카드는 connection_enabled 가 기준이라 설정만 채우면 그대로 다시 나타난다
- ★ 코드를 지우지 않았다 — 되돌릴 때 커밋을 되짚지 않고 값 둘만 채우면 된다

★ Threads 카드와 판단이 갈린 것이 맞다. 저쪽은 사장님이 곧 쓸 수 있는 기능이라
자리를 두고 버튼만 죽였고, 이쪽은 언제 열릴지 말해 줄 수 없다.

test_agent_runtime(스위치 2건 추가)·test_kakao_link 34 passed. npm run lint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:15:03 +09:00

182 lines
9.5 KiB
Markdown

# 사장님 에이전트 — 신원 연결 · 도구 · 런타임
사장님이 말로 사이트를 운영하는 것이 목표다 — 내용 고치기, 사진 내리기, 발행, SNS 게재까지.
**에이전트는 카카오톡 안에 있지 않다.** 카톡은 입구 하나이고, 같은 에이전트가 빌더 화면에도
붙는다. 그래야 채널·챗봇 심사 전에 전부 검증된다.
지금까지 만든 것은 **1단계(신원 연결)****2단계(도구·런타임·빌더 채팅창)** 다.
카카오 채널 웹훅은 아직 없다.
## ★ 지금은 화면에서 감춰져 있다 (2026-09-21 보류)
카카오톡 채널 개설이 **법인폰 본인인증**에 걸려 보류됐다. 채널이 없으면 이 기능은
사장님에게 **어디에도 닿지 않는 입구**다 — 열어 두면 "되는 기능" 으로 오해한다.
| 화면 | 감추는 조건 |
|---|---|
| 대화창(`AgentChatDock`) | `AGENT_CHAT_ENABLED=0` (기본값) |
| 연결 카드(`KakaoChannelCard`) | `KAKAO_CHANNEL_PUBLIC_ID` 가 빔 (기본값) |
**코드는 그대로 두고 설정으로만 닫았다.** 채널이 준비되면 값 둘을 채우고 다시 띄우면 된다 —
되돌릴 때 커밋을 되짚지 않는다. 서버도 함께 닫힌다(`runtime.is_configured()` 가 스위치를
보므로, 화면을 우회해 API 를 직접 불러도 `AGENT_NOT_CONFIGURED` 다).
★ Threads 카드는 반대로 '자리는 두고 버튼만 죽이는' 쪽이다. 저쪽은 사장님이 **곧 쓸 수 있는**
기능이라 존재를 알려야 했고, 이쪽은 언제 열릴지 말해 줄 수 없다. 판단이 갈린 이유가 그것이다.
## 왜 신원 연결이 먼저인가
카카오 채널이 주는 발화자 식별자는 **채널 단위 익명 키**다. 우리 `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()` 은 서비스 함수로만 있고 라우터에 붙어 있지 않다.
## 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` | 바로 답한다 |
| `REVERSIBLE` | `set_fact` | 실행하고 알린다 |
| `SEMI` | `publish` | **실행 전에 한 번 묻는다** |
**등급은 레지스트리가 못 박는다.** 모델이 정하게 두면 프롬프트에 끼어든 한 줄이 확인
절차를 건너뛴다. 그래서 응답 스키마에 등급 칸 자체가 없고, 도구 목록에도 등급을 싣지 않는다.
**결과 문구는 도구가 만든다.** LLM 이 쓰게 두면 **하지 않은 일을 했다고 말할 수 있고**,
사장님에게는 그 말이 사실로 보인다. 모델 문장은 '되묻기' 에만 쓴다.
**값을 고치면 재발행 안내를 함께 낸다.** fact 는 바뀌어도 사이트는 안 바뀐다 —
이 한 줄이 빠지면 사장님은 반영된 줄 알고 확인하러 갔다가 옛 값을 보고 "고장났네" 가 된다.
**모호하면 실행하지 않고 되묻는다.** 티오더가 "유사한 메뉴가 2개 이상이면 후보 목록을 제시"
로 푼 문제와 같다 — 추측으로 고르면 사장님이 그걸 못 알아채고 넘어간다.
## 확인(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` 가 소스에서 그 호출이 없는지 실제로 검사한다.