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

9.5 KiB

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

사장님이 말로 사이트를 운영하는 것이 목표다 — 내용 고치기, 사진 내리기, 발행, 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 카드에서 실제로 겪었다).

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() 은 서비스 함수로만 있고 라우터에 붙어 있지 않다.

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.pysocial_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 가 소스에서 그 호출이 없는지 실제로 검사한다.