diff --git a/.env.example b/.env.example index c955e38..e0c9cfa 100644 --- a/.env.example +++ b/.env.example @@ -83,9 +83,17 @@ ALIMTALK_TEMPLATE_CODE= # 묶어야 한다. 빌더에서 코드를 받아 채널에 한 번 입력하는 절차다. # ★ 이 값이 비면 연결 화면이 아예 안 뜬다 — 어디에 코드를 칠지 말해 줄 수 없는데 # 코드만 발급하면 사장님에게는 고장난 화면이다. -# ★ 사장님 대화창(에이전트). 기본 꺼짐 — 카카오톡 채널이 준비되기 전에는 띄우지 않는다. -# 코드는 다 있지만 채널 없이 열어 두면 사장님에게는 어디에도 닿지 않는 입구다. -AGENT_CHAT_ENABLED=0 +# 사장님 대화창(에이전트). 1=사용, 0=감춤. +# ★ 1 이어도 LLM 키가 없으면 안 열린다 — 키 없는 환경에서 켜 둔 채 잊어도 +# "눌러도 안 되는 입구" 가 생기지 않는다. +AGENT_CHAT_ENABLED=1 +# 카카오톡 채널 웹훅(오픈빌더 스킬 서버). ★ 오픈빌더는 서명을 주지 않는다 — +# URL 만 알면 누구나 때릴 수 있고 발화자 id 를 위조하면 그 사장님 행세를 한다. +# 비우면 웹훅 엔드포인트가 404 다(반쯤 열린 상태를 만들지 않는다). +# 만드는 법: python -c "import secrets; print(secrets.token_urlsafe(32))" +KAKAO_WEBHOOK_SECRET= +# 우리 봇이 맞는지 한 겹 더. 오발송을 거르는 용도라 비워도 된다. +KAKAO_BOT_ID= KAKAO_CHANNEL_PUBLIC_ID= KAKAO_LINK_CODE_TTL_MIN=10 KAKAO_LINK_MAX_ATTEMPTS=5 diff --git a/AGENTS.md b/AGENTS.md index 1778e25..8643398 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,9 +169,19 @@ `services/*` 를 통과한다. `collect_service.store_facts` 가 크롤러에 걸어 둔 그 문이다. - **카카오 채널 발화자는 우리 `user_id` 가 아니다** — 채널 단위 익명 키다. `owner_kakao_links` 매핑 없이 발화자를 믿으면 **채널 진입점만 소유자 범위 밖**에 놓인다. -- **에이전트 화면은 2026-09-21 기준 감춰져 있다** — `AGENT_CHAT_ENABLED=0`(기본) · - `KAKAO_CHANNEL_PUBLIC_ID` 빔. 카카오톡 채널이 보류돼서이고 **코드는 멀쩡하다**. - "기능이 없다" 고 판단해 지우지 않는다([AGENT.md](docs/AGENT.md)). +- **★ 카카오 웹훅은 서명이 없다 — 시크릿이 유일한 문이다.** 오픈빌더는 서명을 주지 않아서, + URL 만 알면 누구나 때릴 수 있고 `userRequest.user.id` 를 위조하면 **그 사장님 행세**를 한다. + `KAKAO_WEBHOOK_SECRET` 이 비면 엔드포인트가 **404**(401 은 존재를 알린다). +- **확인 대기에 만료가 없으면 묵은 발행이 돈다** — 카카오톡은 앞선 답을 되돌려 주지 않아 + 서버가 pending 을 들고 있는다. `pending_expires_at`(3분)을 빼면 한참 뒤의 "네" 한 마디에 + 실행된다([AGENT.md](docs/AGENT.md)). +- **바로가기 라벨과 '예' 로 읽는 말이 어긋나면 눌러도 안 먹는다** — 사장님은 버튼이 고장난 + 줄 안다. `channel.py` 의 `CONFIRM_LABEL` 상수를 쓰고 문자열을 손으로 적지 않는다. +- **에이전트 대화창은 스위치와 LLM 키를 둘 다 본다**(`AGENT_CHAT_ENABLED`, 기본 `1`). + 키만 보면 "잠시 닫아 두기" 가 키를 지우는 일이 되어 소개문·사진분류까지 꺼지고, + 스위치만 보면 키 없는 환경에 **눌러도 안 되는 입구**가 생긴다. + 카카오 연결 카드는 `KAKAO_CHANNEL_PUBLIC_ID` 가 비면 감춰진다 — + 웹훅(4단계)이 없어 코드를 보내도 연결이 완성되지 않기 때문이다([AGENT.md](docs/AGENT.md)). - **에이전트 등급을 모델이 정하게 두지 않는다** — 확인이 필요한 행위인지는 `services/agent/tools.py` 레지스트리가 못 박는다. 응답 스키마에 그 칸을 만들면 프롬프트에 끼어든 한 줄이 확인 절차를 건너뛴다. - **실행 결과 문구를 LLM 이 쓰게 두지 않는다** — 모델은 **하지 않은 일을 했다고 말할 수 있고**, diff --git a/docs/AGENT.md b/docs/AGENT.md index a3228a0..754585c 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -4,25 +4,27 @@ **에이전트는 카카오톡 안에 있지 않다.** 카톡은 입구 하나이고, 같은 에이전트가 빌더 화면에도 붙는다. 그래야 채널·챗봇 심사 전에 전부 검증된다. -지금까지 만든 것은 **1단계(신원 연결)** 와 **2단계(도구·런타임·빌더 채팅창)** 다. -카카오 채널 웹훅은 아직 없다. +1단계(신원 연결) · 2단계(도구·런타임·빌더 채팅창) · **4단계(카카오 웹훅)** 을 만들었다. +남은 것은 오픈빌더 챗봇 등록(우리가 못 하는 일)과 도구 늘리기다. -## ★ 지금은 화면에서 감춰져 있다 (2026-09-21 보류) +## 화면 스위치 -카카오톡 채널 개설이 **법인폰 본인인증**에 걸려 보류됐다. 채널이 없으면 이 기능은 -사장님에게 **어디에도 닿지 않는 입구**다 — 열어 두면 "되는 기능" 으로 오해한다. +| 화면 | 여는 조건 | 지금 | +|---|---|---| +| 대화창(`AgentChatDock`) | `AGENT_CHAT_ENABLED=1`(기본) **그리고** LLM 키 | 열림 | +| 연결 카드(`KakaoChannelCard`) | `KAKAO_CHANNEL_PUBLIC_ID` 가 채워짐 | 채널 ID 미설정 | -| 화면 | 감추는 조건 | -|---|---| -| 대화창(`AgentChatDock`) | `AGENT_CHAT_ENABLED=0` (기본값) | -| 연결 카드(`KakaoChannelCard`) | `KAKAO_CHANNEL_PUBLIC_ID` 가 빔 (기본값) | +★ 스위치와 키를 **둘 다** 본다(`runtime.is_configured`). 키만 보면 "잠시 닫아 두기" 를 키를 +지워서 해야 하고 그러면 소개문·사진분류까지 같이 꺼진다. 스위치만 보면 키 없는 환경에서 +**눌러도 안 되는 입구**가 생긴다. -**코드는 그대로 두고 설정으로만 닫았다.** 채널이 준비되면 값 둘을 채우고 다시 띄우면 된다 — -되돌릴 때 커밋을 되짚지 않는다. 서버도 함께 닫힌다(`runtime.is_configured()` 가 스위치를 -보므로, 화면을 우회해 API 를 직접 불러도 `AGENT_NOT_CONFIGURED` 다). +★ 2026-09-21 에 카카오 채널 개설이 법인폰 본인인증에 걸려 한 번 닫았고, +인증이 끝나 2026-09-22 에 다시 열었다. **그때도 코드는 지우지 않고 값만 바꿨다** — +닫고 여는 일이 커밋을 되짚는 일이 되면 안 된다. -★ Threads 카드는 반대로 '자리는 두고 버튼만 죽이는' 쪽이다. 저쪽은 사장님이 **곧 쓸 수 있는** -기능이라 존재를 알려야 했고, 이쪽은 언제 열릴지 말해 줄 수 없다. 판단이 갈린 이유가 그것이다. +★ 연결 카드를 '감추는' 쪽으로 둔 것은 Threads 카드('자리는 두고 버튼만 죽인다')와 반대 +판단인데 의도한 차이다. 저쪽은 사장님이 곧 쓸 수 있는 기능이라 존재를 알려야 했고, +이쪽은 웹훅(4단계)이 없어 아직 연결이 **완성되지 않는다**. ## 왜 신원 연결이 먼저인가 @@ -77,13 +79,10 @@ RETURNING user_id; **실패는 전부 같은 에러다**(`KAKAO_LINK_CODE_INVALID`). "없는 코드"·"만료"·"시도 초과" 를 구분해 답하면 6자리 코드의 유효성을 외부에서 탐색할 수 있다. -## ★ 소비 엔드포인트는 아직 없다 +## 코드는 웹훅에서만 소비된다 -코드를 소비하는 쪽은 **채널 웹훅**이고, 그 웹훅은 자체 서명 검증을 갖춘 뒤에야 열 수 있다. -검증 없는 공개 소비 경로를 먼저 만들면 누구나 코드를 대입해 남의 계정에 자기 카톡을 붙인다 — -이 표가 막으려던 바로 그 일이다. - -지금 `redeem()` 은 서비스 함수로만 있고 라우터에 붙어 있지 않다. +`redeem()` 은 **공개 라우터에 붙어 있지 않다.** 시크릿 검증을 통과한 웹훅 안에서만 불린다 — +검증 없는 공개 소비 경로가 있으면 누구나 6자리를 대입해 남의 계정에 자기 카톡을 붙인다. ## API @@ -179,3 +178,96 @@ services/fact_service.py · site_service.py ★ 게이트가 사는 곳 검증·출처 필수·정정본 보호가 **아무 증상 없이** 사라진다. `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/<시크릿>` 을 쓴다. + +★ **채널 '채팅' 과 '챗봇(오픈빌더 스킬)' 은 다른 기능이다.** 채팅만 켜면 발화가 우리에게 +오지 않는다 — 웹훅이 붙는 쪽은 챗봇이고, 봇을 만들어 채널에 연결해야 한다. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 01ceb00..65ea3f5 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -1,5 +1,87 @@ # 개발 일지 +## 2026-09-22 — 카톡 5초 벽을 콜백으로 넘는다 + +실제 카톡에서 "시설 편의에서 바비큐 이용 문구 빼줘" 가 **"확인하는 데 시간이 조금 걸리네요"** +로 끝났다. 타임아웃이었다. + +★ **작은 표본으로 잰 수치를 상한 근거로 삼은 것이 틀렸다.** 개발 중 잰 1.3~2.4초는 업종 필드 +두 개짜리 장난감 프롬프트였고, 진짜 요청에는 필드 43개 + fact 수십 개가 실린다. +"여유가 있다" 고 적어 둔 판단이 실사용 첫날에 깨졌다. + +**고친 방법** — 오픈빌더 콜백(스킬 타임아웃 5초, 콜백 주소 1분·1회): +`userRequest.callbackUrl` 이 실려 오면 `{"useCallback": true}` 로 **즉답**하고, 백그라운드에서 +답을 만든 뒤 그 주소로 따로 POST 한다. 콜백이 꺼져 있으면 예전처럼 동기(4.5초 상한). + +★ 콜백 전송 실패는 **재시도하지 않는다** — 1회용 주소라 두 번째 POST 는 거절되고, 사장님에게는 +이미 "확인하고 있어요" 가 가 있다. + +★ 오픈빌더 스킬 설정에서 **콜백 사용을 켜야** 이 경로가 열린다. 안 켜면 코드가 있어도 +`callbackUrl` 이 안 와서 동기 경로로만 돈다 — 조용히 예전처럼 동작한다. + +**검증** — `test_kakao_webhook.py` 24 passed(콜백 3건 추가: 즉답 형식·콜백 전송·전송 실패). + +## 2026-09-22 — 카톡 대화에 홈페이지 목록·가게 고르기 + +실제로 붙여 보니 빠진 것이 드러났다(사장님 지적): 연결은 됐는데 **어느 홈페이지를 다루는 +대화인지 화면이 말해 주지 않았다.** 가게가 하나면 말없이 자동 선택돼 더 모호했다. + +- 연결 직후 목록을 보여준다. 하나면 그 이름과 발행 여부를, 여럿이면 **바로가기 버튼**으로 고르게. +- 목록 줄에 **발행 여부**를 적는다 — 안 그러면 고친 것이 손님에게 보이는 줄 안다. +- "목록"·"가게 바꿔줘" 등으로 **언제든 돌아와 바꾼다.** ★ 이 경로는 LLM 을 부르지 않는다 — + 대화가 막혔을 때 처음 찾는 길이라 늘 통해야 하고, 목록 보기에 돈을 쓸 이유가 없다. +- 목록은 `list_my_sites` 를 쓴다(사업장 목록이 아니라). `/sites` 화면이 같은 이유로 그걸 쓴다 — + 사장님이 알아야 하는 건 "가게가 있다" 가 아니라 "발행돼 있나" 다. + +**검증** — `test_kakao_webhook.py` 21 passed(목록·전환 4건 추가). +전체 `845 passed / 53 failed`, 53 은 이번 변경 전과 같다. + +## 2026-09-22 — 카카오 채널 웹훅(4단계) + +카카오톡 채널이 준비돼 웹훅을 만들었다. **런타임은 한 줄도 안 바뀌었다** — 채널을 모르게 +만들어 둔 것이 여기서 값을 했다. 새로 생긴 것은 형식 변환(`kakao_bot.py`)과 대화 상태 +(`channel.py`)뿐이다. + +**★★ 인증 — 오픈빌더는 서명을 주지 않는다** +URL 만 알면 누구나 때릴 수 있고 `userRequest.user.id` 를 위조하면 **그 사장님 행세**를 한다. +1단계에서 만든 신원 연결이 통째로 무의미해지는 자리다. 공유 시크릿(헤더 `X-Agent-Secret`, +`hmac.compare_digest`) + 선택적 `KAKAO_BOT_ID` 대조로 막고, 시크릿이 없으면 **엔드포인트가 +404** 다 — 401 은 "여기 뭔가 있다" 를 알려 준다. + +**빌더 화면과 다른 것 셋** — 나머지는 `runtime.chat()` 그대로다. +1. 로그인 토큰이 없다 → 발화자 키로 사장님을 찾는다. ★ **토큰을 발급하지 않는다** + (카톡 경로에서 JWT 가 나오면 그게 곧 권한 탈취 경로다) +2. `place_id` 가 URL 에 없다 → 대화에서 고르고 `current_place_id` 에 기억. + ★ 여럿인데 안 정해졌으면 **추측하지 않고 되묻는다** +3. 확인을 되돌려 줄 프론트가 없다 → 서버가 pending 을 들고 있는다(0022). + ★ `pending_expires_at`(3분)이 없으면 **한참 뒤의 "네" 한 마디에 묵은 발행이 돈다** + +**5초 벽** — `DEADLINE_SEC=4.0`. 넘기면 카카오가 끊어 말없이 실패하는 봇이 되므로 안내로 +끊는다. 도구 선택 실측이 1.3~2.4초라 여유가 있다. 콜백은 오픈빌더 지원 여부 확인 뒤에. +어떤 실패도 **200 + 안내 문구**다 — 메신저에서는 500 도 침묵으로 보인다. + +**밟은 것** — `DB_SESSION_MNG.execute_lambda` 는 **람다 반환값을 그대로** 준다(CRUD 관례가 +`(ErrorType, 값)`). 우리 람다가 객체만 돌려주자 언패킹에서 TypeError 가 났고, 라우터가 모든 +예외를 삼키는 구조라 화면에는 "지금은 처리할 수 없어요" 한 줄만 보였다 — 원인이 안 보이는 종류다. + +**검증** — `test_kakao_webhook.py` 17 passed(시크릿·위조·만료·가게 되묻기·5초·형식 누출). +전체 `841 passed / 53 failed` 이고 그 53 은 이번 변경 전과 같다. + +## 2026-09-22 — 에이전트 대화창 다시 염(기본 켜짐) + +카카오톡 채널의 통신사 인증이 끝나 보류를 푼다(사장님 지시). `AGENT_CHAT_ENABLED` 기본값을 +`0` → `1` 로 돌렸다. **코드는 어제도 오늘도 그대로다** — 닫고 여는 일이 커밋을 되짚는 일이 +되면 안 된다는 어제 판단이 하루 만에 값을 쳤다. + +★ 기본을 켜도 **LLM 키가 없으면 안 열린다**(`runtime.is_configured` 가 스위치와 키를 둘 다 +본다). 키 없는 환경에서 켜 둔 채 잊어도 "눌러도 안 되는 입구" 가 생기지 않는다. + +★ 카카오 연결 카드는 아직 감춰져 있다 — `KAKAO_CHANNEL_PUBLIC_ID` 미설정. +채우면 코드는 발급되지만 **소비할 웹훅(4단계)이 없어 연결이 완성되지 않는다.** +채널 '채팅' 과 '챗봇(오픈빌더 스킬)' 은 다른 기능이고, 웹훅이 붙는 쪽은 후자다. + +**검증** — `test_agent_runtime`(스위치 테스트를 새 기본값에 맞춰 갱신)·`test_kakao_link` 34 passed. + ## 2026-09-21 — 에이전트 화면 보류: 설정으로 닫는다(코드는 그대로) 카카오톡 채널 개설이 **법인폰 본인인증**에 걸려 보류됐다(사장님 지시: "이 작업은 여기서 딱 diff --git a/docs/MINI_BLOG.md b/docs/MINI_BLOG.md index 19ec92e..1204b7a 100644 --- a/docs/MINI_BLOG.md +++ b/docs/MINI_BLOG.md @@ -106,7 +106,12 @@ - 본문: 문구 전문 + 승인 링크 + 수정 링크(`blog_jobs._mail_body`) - **승인 링크**: `GET /v1/site/post/approve?t=<토큰>` — 로그인 없음, 토큰이 신원. **누르는 즉시 승인된다**(확인 화면 없음, 2026-09-17 사장님 지시). 토큰은 32바이트 랜덤 → DB 엔 - sha256 만, **단회용 · 그날 자정(KST) 만료**(`blog_service.issue_token`) + sha256 만, **단회용 · 그날 자정(KST) 만료**(`blog_service.issue_token`). 승인 확인 + 화면은 그 업장의 발행된 사이트(미니 블로그 자리, `#blog`)로 5초 뒤 자동 이동한다 + (2026-09-22 사장님 지시 — `router/v1/site/post.py _page`, `PostService._blog_url`). + 재발행(BUILD 잡)은 몇 분 걸리므로 5초 뒤에 이 글이 이미 반영돼 있다는 보장은 없다 — + 그래도 "어디로 가면 보이는지"는 바로 알려준다. 발행된 사이트가 없으면 자동 이동 없이 + 확인 문구만 보여준다 - **수정 링크**: `{origin}/blog?placeId=&postId=&auto=<그날짜리 JWT>` — 로그인 흐름이다. `CreateDayPassToken`(`router/v1/validator/dependencies.py`)이 자정까지만 사는 접근 토큰을 찍고, 빌더 앱이 그 토큰으로 로그인해 그 글의 편집 모달을 바로 연다 diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 419a87a..52e94df 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -636,6 +636,11 @@ CREATE TABLE IF NOT EXISTS public.owner_kakao_links ( status varchar(16) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING','LINKED','REVOKED')), linked_at timestamptz, last_seen_at timestamptz, + -- 대화 상태(migrations/0022) — 카카오톡은 앞선 답을 되돌려 주지 않는다. + current_place_id uuid, + pending_tool varchar(40), + pending_args jsonb, + pending_expires_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), deleted boolean NOT NULL DEFAULT false diff --git a/postgres-init/migrations/0022_owner_kakao_links_conversation.sql b/postgres-init/migrations/0022_owner_kakao_links_conversation.sql new file mode 100644 index 0000000..b2f0447 --- /dev/null +++ b/postgres-init/migrations/0022_owner_kakao_links_conversation.sql @@ -0,0 +1,14 @@ +-- 0022 · owner_kakao_links 에 대화 상태 — 카카오톡은 앞선 답을 되돌려 주지 않는다. +-- +-- ★ 빌더 화면은 확인(SEMI) 한 바퀴를 프론트가 이어 줬다. `{confirm:{tool,args}}` 를 그대로 +-- 돌려보내므로 서버가 아무것도 기억하지 않아도 됐다. +-- 카카오톡에서 돌아오는 것은 **텍스트 한 줄**뿐이다("네, 해주세요"). 그래서 무엇을 물었는지 +-- 서버가 들고 있어야 한다. +-- +-- ★ pending_expires_at 이 없으면 조용히 틀린다: 사장님이 한참 뒤 다른 맥락에서 "네" 라고 +-- 치는 순간 **묵은 발행이 실행된다.** 그 사이에 값이 더 바뀌었을 수도 있다. +ALTER TABLE public.owner_kakao_links + ADD COLUMN IF NOT EXISTS current_place_id uuid, + ADD COLUMN IF NOT EXISTS pending_tool varchar(40), + ADD COLUMN IF NOT EXISTS pending_args jsonb, + ADD COLUMN IF NOT EXISTS pending_expires_at timestamptz; diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index 81fdb5c..592342b 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -721,6 +721,12 @@ class owner_kakao_links(MainTableMixin, MAIN_BASE): status = Column(String(16), nullable=False, server_default=text("'PENDING'")) linked_at = Column(DateTime(timezone=True), nullable=True) last_seen_at = Column(DateTime(timezone=True), nullable=True) + # 대화 상태 — 카카오톡은 앞선 답을 되돌려 주지 않는다(빌더 화면은 프론트가 이어 줬다). + # ★ pending_expires_at 이 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다. + current_place_id = Column(UUID(as_uuid=True), nullable=True) + pending_tool = Column(String(40), nullable=True) + pending_args = Column(JSONB, nullable=True) + pending_expires_at = Column(DateTime(timezone=True), nullable=True) __table_args__ = ( Index("uq_kakao_link_user", "user_id", unique=True, postgresql_where=text("deleted=false AND status IN ('PENDING','LINKED')")), Index("uq_kakao_link_channel_key", "channel_user_key", unique=True, postgresql_where=text("deleted=false AND status='LINKED'")), diff --git a/solution/backend/config/agent_config.py b/solution/backend/config/agent_config.py index 7d13cd9..5fd4107 100644 --- a/solution/backend/config/agent_config.py +++ b/solution/backend/config/agent_config.py @@ -23,10 +23,22 @@ class AgentConfig(BaseSettings): # 코드가 짧아서(사람이 손으로 친다) 무차별 대입이 가능하다. 시도 수로 끊는다. KAKAO_LINK_MAX_ATTEMPTS: int = 5 - # ★ 기본 꺼짐. 카카오톡 채널 개설이 법인폰 본인인증에 걸려 보류됐고(2026-09-21), - # 채널 없이 대화창만 띄우면 사장님에게는 **어디에도 닿지 않는 입구**가 된다. - # 코드는 그대로 두고 이 값으로만 연다 — 되돌릴 때 커밋을 되짚지 않아도 된다. - AGENT_CHAT_ENABLED: str = "0" + # 빌더 화면의 대화창. 2026-09-21 에 한 번 닫았다가(카카오 채널 보류) 채널 인증이 + # 끝나 다시 열었다(2026-09-22). + # ★ 이 값이 "1" 이어도 **LLM 키가 없으면 안 열린다**(runtime.is_configured 가 둘 다 본다) — + # 키 없는 환경에서 켜 둔 채 잊어도 "눌러도 안 되는 입구" 가 생기지 않는다. + # 다시 닫을 일이 생기면 이 값만 "0" 으로 되돌린다. 코드를 되짚지 않는다. + AGENT_CHAT_ENABLED: str = "1" + + # ★ 카카오 웹훅 인증. **오픈빌더는 서명을 주지 않는다** — URL 만 알면 누구나 이 엔드포인트를 + # 때릴 수 있고, user.id 를 아무 값이나 넣으면 **그 사장님 행세를 한다.** 신원 연결 + # (owner_kakao_links)이 통째로 무의미해진다. + # 그래서 이 값이 없으면 **엔드포인트 자체를 띄우지 않는다**(404). 반쯤 열린 상태를 + # 만들지 않는 것은 Threads 연결과 같은 규칙이다. + # 만드는 법: python -c "import secrets; print(secrets.token_urlsafe(32))" + KAKAO_WEBHOOK_SECRET: str = "" + # 우리 봇이 맞는지 한 겹 더 본다. 시크릿이 아니라 오발송을 거르는 용도라 비워도 된다. + KAKAO_BOT_ID: str = "" def get(name, default=""): @@ -37,6 +49,10 @@ def chat_enabled() -> bool: return get("AGENT_CHAT_ENABLED", "0") == "1" +def webhook_secret() -> str: + return get("KAKAO_WEBHOOK_SECRET") + + def kakao_link_enabled() -> bool: return bool(get("KAKAO_CHANNEL_PUBLIC_ID")) diff --git a/solution/backend/router/router.py b/solution/backend/router/router.py index d87044b..cf46e52 100644 --- a/solution/backend/router/router.py +++ b/solution/backend/router/router.py @@ -29,6 +29,7 @@ import router.v1.social.social import router.v1.social.oauth import router.v1.agent.kakao import router.v1.agent.chat +import router.v1.agent.kakao_bot API_SERVER_START_TIME = GTime.UTCStr() @@ -142,3 +143,4 @@ app.include_router(router.v1.social.social.router) app.include_router(router.v1.social.oauth.router) app.include_router(router.v1.agent.kakao.router) app.include_router(router.v1.agent.chat.router) +app.include_router(router.v1.agent.kakao_bot.router) diff --git a/solution/backend/router/v1/agent/kakao_bot.py b/solution/backend/router/v1/agent/kakao_bot.py new file mode 100644 index 0000000..458c0a2 --- /dev/null +++ b/solution/backend/router/v1/agent/kakao_bot.py @@ -0,0 +1,147 @@ +"""카카오톡 채널 웹훅(오픈빌더 스킬 서버) — 카카오 형식은 **이 파일 밖으로 나가지 않는다**. + +`version: "2.0"` · `simpleText` · `quickReplies` 같은 모양이 서비스 계층에 새면, 다른 채널을 +붙일 때 그걸 전부 걷어내야 한다. 알림톡 어댑터에 건 것과 같은 규칙이다. + +★★ **오픈빌더는 서명을 주지 않는다.** URL 만 알면 누구나 이 엔드포인트를 때릴 수 있고, + `userRequest.user.id` 를 아무 값이나 넣으면 **그 사장님 행세를 한다** — 신원 연결 + (`owner_kakao_links`)이 통째로 무의미해진다. 그래서 공유 시크릿을 우리가 직접 댄다. + 시크릿이 없으면 **엔드포인트 자체를 띄우지 않는다(404)** — 반쯤 열린 상태를 만들지 않는 것은 + Threads 연결과 같은 규칙이다. + +★ 5초 벽: 오픈빌더의 스킬 타임아웃은 **5초**다. 넘기면 카카오가 끊어 사장님에게는 + **말없이 실패하는 봇**이 된다. + → 오픈빌더 스킬 설정에서 **콜백 사용**을 켜면 요청에 `userRequest.callbackUrl` 이 실려 온다. + 그때는 `{"useCallback": true}` 로 **즉답**하고, 답을 다 만든 뒤 그 주소로 따로 보낸다. + 콜백 주소는 **1분 · 1회**만 유효하다. + → 콜백이 꺼져 있으면 예전처럼 동기로 답하되 `DEADLINE_SEC` 로 끊는다. 실측(2026-09-22): + 필드 43개 + fact 수십 개가 실린 실제 프롬프트는 4초를 넘겼다 — 개발 중 재본 + 1.3~2.4초는 항목 두 개짜리 장난감 프롬프트였다. +""" + +import asyncio +import hmac + +import httpx +from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request + +from common.logger import LOG +from config import agent_config as config +from services.agent import channel + +router = APIRouter(prefix="/v1/agent/kakao", tags=["Agent"]) + +# 콜백이 꺼져 있을 때만 쓰는 상한. 카카오가 5초에 끊으므로 그보다 살짝 앞에서 우리가 끊는다 — +# 침묵보다 "잠시 뒤 다시" 가 낫다. +DEADLINE_SEC = 4.5 +# 콜백이 켜져 있을 때의 상한. 콜백 주소가 1분간 유효하므로 그 안에서 넉넉히 잡는다. +CALLBACK_DEADLINE_SEC = 45.0 + +_TIMEOUT_TEXT = "확인하는 데 시간이 조금 걸리네요. 잠시 뒤 다시 말씀해 주세요." +_ERROR_TEXT = "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요." +_WAIT_TEXT = "확인하고 있어요. 잠시만 기다려 주세요." + + +def _reply(text: str, quick_replies=None) -> dict: + """오픈빌더 스킬 응답(SkillResponse). ★ 카카오 형식을 아는 유일한 함수다.""" + payload: dict = {"outputs": [{"simpleText": {"text": text}}]} + if quick_replies: + # 바로가기는 최대 10개. 누르면 그 라벨이 **다음 발화로 그대로 들어온다** — + # channel.py 의 _YES/_NO 가 같은 문자열을 알고 있어야 먹는다. + payload["quickReplies"] = [ + {"label": label, "action": "message", "messageText": label} for label in quick_replies[:10] + ] + return {"version": "2.0", "template": payload} + + +def _authorize(secret_in_path: str | None, header_secret: str | None, body: dict) -> None: + expected = config.webhook_secret() + if not expected: + # 설정이 없으면 이 기능은 존재하지 않는다. 401 로 답하면 엔드포인트의 존재를 알린다. + raise HTTPException(404) + given = header_secret or secret_in_path or "" + if not hmac.compare_digest(given, expected): + LOG.w("[agent/kakao] 웹훅 시크릿 불일치 — 거절") + raise HTTPException(404) + + # 한 겹 더. 시크릿이 아니라 오발송을 거르는 용도라 비워 두면 검사하지 않는다. + bot_id = config.get("KAKAO_BOT_ID") + if bot_id and (body.get("bot") or {}).get("id") != bot_id: + LOG.w("[agent/kakao] 다른 봇의 요청 — 거절") + raise HTTPException(404) + + +async def _answer(utterance: str, speaker: str, deadline: float) -> dict: + """대화 한 턴을 SkillResponse 로. 어떤 실패도 문구로 바꾼다.""" + try: + answer = await asyncio.wait_for(channel.handle(utterance, speaker), timeout=deadline) + except asyncio.TimeoutError: + LOG.w("[agent/kakao] 응답 시간 초과 — 안내로 끊음") + return _reply(_TIMEOUT_TEXT) + except Exception as ex: # noqa: BLE001 — 메신저에서는 500 도 침묵으로 보인다 + LOG.w(f"[agent/kakao] 처리 실패: {type(ex).__name__}") + return _reply(_ERROR_TEXT) + return _reply(answer["text"], answer.get("quick_replies")) + + +async def _push(callback_url: str, utterance: str, speaker: str) -> None: + """답을 다 만든 뒤 콜백 주소로 보낸다. + + ★ 주소는 1분 · 1회만 유효하다. 실패해도 재시도하지 않는다 — 두 번째 POST 는 어차피 + 거절되고, 사장님에게는 이미 "확인하고 있어요" 가 가 있다.""" + payload = await _answer(utterance, speaker, CALLBACK_DEADLINE_SEC) + try: + async with httpx.AsyncClient(timeout=10.0) as client: + res = await client.post(callback_url, json=payload) + if res.status_code >= 400: + LOG.w(f"[agent/kakao] 콜백 전송 실패: {res.status_code}") + except Exception as ex: # noqa: BLE001 + LOG.w(f"[agent/kakao] 콜백 전송 실패: {type(ex).__name__}") + + +async def _handle(body: dict, tasks: BackgroundTasks) -> dict: + request = body.get("userRequest") or {} + utterance = request.get("utterance") or "" + speaker = (request.get("user") or {}).get("id") or "" + if not speaker: + # 발화자를 모르면 누구의 가게인지도 모른다. 여기서 끝낸다. + return _reply("사용자를 확인하지 못했어요.") + + # ★ 콜백이 켜져 있으면 5초 벽을 넘을 수 있다. 즉답하고 뒤에서 마저 만든다. + callback_url = request.get("callbackUrl") + # ★ "콜백을 켰는데 왜 안 되나" 를 눈으로 가릴 수 있게 남긴다. 어느 블록이 도는지도 같이 — + # 스킬이 폴백이 아닌 다른 블록에 붙어 있으면 콜백 설정이 그 블록에 없어 조용히 동기로 돈다. + LOG.i(f"[agent/kakao] 요청 — callbackUrl={'있음' if callback_url else '없음'} " + f"block={(request.get('block') or {}).get('name')!r}") + if callback_url: + tasks.add_task(_push, callback_url, utterance, speaker) + return {"version": "2.0", "useCallback": True, "data": {"text": _WAIT_TEXT}} + + return await _answer(utterance, speaker, DEADLINE_SEC) + + +@router.post("/webhook") +async def webhook( + request: Request, + tasks: BackgroundTasks, + x_agent_secret: str | None = Header(default=None), +): + """헤더로 시크릿을 받는 쪽. 스킬 설정에서 커스텀 헤더를 넣을 수 있으면 이쪽을 쓴다.""" + body = await request.json() + _authorize(None, x_agent_secret, body) + return await _handle(body, tasks) + + +@router.post("/webhook/{secret}") +async def webhook_with_path_secret( + secret: str, + request: Request, + tasks: BackgroundTasks, + x_agent_secret: str | None = Header(default=None), +): + """헤더를 못 넣는 경우의 대안. + + ★ 최후 수단이다 — 경로는 액세스 로그·앞단 프록시에 남는다. 헤더를 쓸 수 있으면 위를 쓴다.""" + body = await request.json() + _authorize(secret, x_agent_secret, body) + return await _handle(body, tasks) diff --git a/solution/backend/router/v1/site/post.py b/solution/backend/router/v1/site/post.py index 889bc86..08fde0e 100644 --- a/solution/backend/router/v1/site/post.py +++ b/solution/backend/router/v1/site/post.py @@ -14,6 +14,7 @@ "바로 발행" — 수정 없이 그대로 승인). PUT(수정)은 저장만 하고 자동으로 승인하지 않는다 — 승인은 이 두 경로 중 하나를 명시적으로 눌러야 한다. """ +import html from datetime import date from uuid import UUID @@ -30,18 +31,27 @@ from services.post_service import PostService router = APIRouter(prefix="/v1/site/post", tags=["Site"]) owner_router = APIRouter(prefix="/v1/place/{place_id}/post", tags=["Site"]) +_REDIRECT_DELAY_SEC = 5 + _PAGE = """ -{title}
{content}
""" -def _page(title: str, content: str) -> HTMLResponse: - return HTMLResponse(_PAGE.format(title=title, content=content)) +def _page(title: str, content: str, *, redirect_url: str | None = None) -> HTMLResponse: + # ★ redirect_url 은 항상 서버가 site_payload.publish_url() 로 만든 값(고정 오리진 + + # slugify 통과 슬러그)이라 사용자 입력이 아니지만, HTML 속성에 그대로 꽂는 자리라 + # escape 를 걸어 둔다 — 이 함수가 나중에 다른 값을 받게 되더라도 안전하게. + redirect = ( + f'' + if redirect_url else "" + ) + return HTMLResponse(_PAGE.format(title=title, content=content, redirect=redirect)) def _expired_page() -> HTMLResponse: @@ -56,7 +66,20 @@ async def approve_page(t: str = Query(min_length=8, max_length=200), service: Po result = await service.decide(t, skip=False) if not result["success"]: return _expired_page() - return _page("올렸습니다", f"

{result['message']}

사이트에 반영되기까지 몇 분 걸립니다.

") + redirect_url = result.get("redirect_url") + # ★ 재발행은 몇 분 걸린다(BUILD 잡) — 5초 뒤에 이 글이 이미 반영돼 있다는 보장은 없다. + # 그래도 "어디로 가면 보이는지" 를 알려주는 게 사장님 입장에서 "눌렀는데 어디 갔지" 보다 + # 낫다(2026-09-22, 사장님 지시). 링크 자체는 안내 문구에도 남겨 자동 이동을 못 믿어도 되게 한다. + extra = ( + f"

{_REDIRECT_DELAY_SEC}초 뒤 자동으로 이동합니다. " + f"바로 가려면 여기를 눌러주세요.

" + if redirect_url else "" + ) + return _page( + "올렸습니다", + f"

{result['message']}

사이트에 반영되기까지 몇 분 걸립니다.

{extra}", + redirect_url=redirect_url, + ) @owner_router.get(path="", response_model=Res_MyPosts, summary="이번 달(또는 고른 달) 생성된 글 목록") diff --git a/solution/backend/scheduler/__init__.py b/solution/backend/scheduler/__init__.py index 982cd00..2549225 100644 --- a/solution/backend/scheduler/__init__.py +++ b/solution/backend/scheduler/__init__.py @@ -54,15 +54,17 @@ def start_scheduler(): _scheduler.add_job(run_scheduled_check, "interval", minutes=10, id="search-console", max_instances=1, coalesce=True) - from scheduler.jobs import sweep_alert_outbox, sweep_blog_drafts, sweep_blog_mail, sweep_queue_health + from scheduler.jobs import sweep_alert_outbox, sweep_blog_mail, sweep_queue_health _scheduler.add_job(sweep_alert_outbox, "interval", minutes=1, id="alert-outbox", max_instances=1, coalesce=True) _scheduler.add_job(sweep_queue_health, "interval", minutes=5, id="queue-health", max_instances=1, coalesce=True) # 미니 블로그 — 새벽에 재고를 채우고, 아침에 검수 통과분을 보낸다(docs/MINI_BLOG.md). # LLM 키나 메일 설정이 없으면 두 잡 모두 아무 일도 안 하고 돌아온다. - _scheduler.add_job(sweep_blog_drafts, "cron", hour=4, minute=10, - id="blog-drafts", max_instances=1, coalesce=True) + # 자동 생성은 잠시 끈다 — 사장님이 빌더에서 '생성'을 눌러야 만들어지는 흐름으로 간다(2026-09-23). + # 되살리려면 위 import 에 sweep_blog_drafts 를 다시 넣고 아래 두 줄 주석을 푼다. + # _scheduler.add_job(sweep_blog_drafts, "cron", hour=4, minute=10, + # id="blog-drafts", max_instances=1, coalesce=True) _scheduler.add_job(sweep_blog_mail, "cron", hour=9, minute=0, id="blog-mail", max_instances=1, coalesce=True) _scheduler.start() diff --git a/solution/backend/services/agent/channel.py b/solution/backend/services/agent/channel.py new file mode 100644 index 0000000..a2bacaf --- /dev/null +++ b/solution/backend/services/agent/channel.py @@ -0,0 +1,288 @@ +"""메신저 대화 한 턴 — 신원 · 가게 고르기 · 확인 이어받기. + +★★ **카카오를 모른다.** `version: "2.0"` · `simpleText` 같은 형식은 한 글자도 여기 없다. + 그건 `router/v1/agent/kakao_bot.py` 안에서 끝난다 — 새면 다른 채널을 붙일 때 전부 + 걷어내야 하고, 알림톡 어댑터에 건 것과 같은 규칙이다. + +★ 빌더 화면과 무엇이 다른가 — 셋뿐이다. + 1. 로그인 토큰이 없다 → 연결된 발화자 키로 사장님을 찾는다 + 2. place_id 가 URL 에 없다 → 대화에서 고르고 기억한다 + 3. 확인을 되돌려 줄 프론트가 없다 → 무엇을 물었는지 서버가 들고 있는다 + 나머지(도구·등급·게이트)는 `runtime.chat()` 그대로다. +""" + +import re +import uuid +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import owner_kakao_links as Link +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 +from services.kakao_link_service import KakaoLinkError + +# 연결 코드 모양(kakao_link_service._CODE_ALPHABET 과 같은 글자 집합). +CODE_PATTERN = re.compile(r"[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}") + +# 확인 대기 수명. ★ 이게 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다. +PENDING_MINUTES = 3 + +# ★ 바로가기 라벨과 '예' 로 읽는 말이 어긋나면 **눌러도 안 먹는다** — 사장님은 버튼이 +# 고장난 줄 안다. 라벨을 상수로 두고 _YES 가 그것을 포함하게 묶는다. +CONFIRM_LABEL = "네, 해주세요" +PUBLISH_LABEL = "네, 발행해주세요" +DECLINE_LABEL = "아니요" + +_YES = {CONFIRM_LABEL, PUBLISH_LABEL, "네", "예", "응", "그래", "네 해주세요", "해주세요", "좋아", "ㅇㅇ", "확인"} +_NO = {DECLINE_LABEL, "아니", "아니오", "안할래", "취소", "나중에", "ㄴㄴ"} + +# 언제든 목록으로 돌아오는 말. ★ LLM 을 부르지 않는다 — 목록 보기에 돈을 쓸 이유가 없고, +# "지금 어느 가게냐" 는 대화가 막혔을 때 가장 먼저 찾는 길이라 늘 통해야 한다. +_LIST_WORDS = { + "목록", "가게 목록", "사이트 목록", "내 사이트", "홈페이지 목록", + "가게 바꿔줘", "가게 변경", "다른 가게", "사이트 바꿔줘", "사이트 변경", +} + + +def _now(): + return datetime.now(timezone.utc) + + +def _say(text: str, quick: list[str] | None = None) -> dict: + """채널이 모르는 모양으로 답한다 — 문구와 바로가기 목록뿐이다.""" + return {"text": text, "quick_replies": quick or []} + + +async def _user_info(user_id) -> UserInfo | None: + """user_id → UserInfo. ★ 토큰을 발급하지 않는다. + + 프로세스 안에서 쓸 객체만 만든다 — 카톡 경로에서 JWT 가 나오면 그게 곧 권한 탈취 + 경로다(docs/AGENT.md).""" + + async def run(s): + row = (await s.execute(select(users).where(users.user_id == user_id, users.deleted.is_(False)))).scalars().first() + return ErrorType.SUCCESS, row + + # ★ execute_lambda 는 람다 반환값을 **그대로** 준다. CRUD 관례(ErrorType, 값)를 따라 + # 우리 람다도 같은 모양으로 돌려준다 — 안 맞추면 여기서 TypeError 로 조용히 죽는다. + err, row = await DB_SESSION_MNG.execute_lambda(users.DBType(), DBWRType.DB_READ.value, run) + if err != ErrorType.SUCCESS or row is None: + return None + return UserInfo(user_id=str(row.user_id), id=row.id, role=row.role, token_version=row.token_version) + + +async def _link_row(channel_user_key: str): + async def run(s): + row = ( + await s.execute( + select(Link).where( + Link.channel_user_key == channel_user_key, + Link.deleted.is_(False), + Link.status == KakaoLinkStatus.LINKED.value, + ) + ) + ).scalars().first() + return ErrorType.SUCCESS, row + + _err, row = await DB_SESSION_MNG.execute_lambda(Link.DBType(), DBWRType.DB_READ.value, run) + return row + + +async def _update_link(channel_user_key: str, **values): + async def run(s): + row = ( + await s.execute( + select(Link).where( + Link.channel_user_key == channel_user_key, + Link.deleted.is_(False), + Link.status == KakaoLinkStatus.LINKED.value, + ) + ) + ).scalars().first() + if row is None: + return None + for name, value in values.items(): + setattr(row, name, value) + return row + + await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run) + + +async def _clear_pending(key): + await _update_link(key, pending_tool=None, pending_args=None, pending_expires_at=None) + + +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): + """어느 가게 이야기인지 정한다. + + ★ 여럿인데 안 정해졌으면 **되묻는다.** 임의로 첫 가게를 고르면, 사장님은 엉뚱한 가게를 + 고쳐 놓고도 그 사실을 모른다 — 화면과 달리 대화에는 "지금 보고 있는 가게" 가 없다. + + 반환: (place_id, 되물을 답 or None)""" + rows = await _sites(user) + if not rows: + return None, _say("아직 등록된 가게가 없어요. 홈페이지를 먼저 만들어 주세요.") + + # ★ 언제든 목록으로 돌아올 수 있어야 한다. 대화가 막혔을 때 처음 찾는 길이다. + 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 = {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}' 으로 맞췄습니다. 무엇을 도와드릴까요?\n" + f"예) 체크인 시간 3시로 바꿔줘") + + 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, _list_reply(rows, "관리 중인 홈페이지입니다.") + + +async def handle(utterance: str, channel_user_key: str) -> dict: + """대화 한 턴. 예외를 던지지 않는다 — 메신저에서는 500 도 침묵으로 보인다.""" + utterance = (utterance or "").strip() + if not utterance: + return _say("무엇을 도와드릴까요?") + + row = await _link_row(channel_user_key) + + # ── 아직 연결되지 않은 발화자 ───────────────────────────────────────── + if row is None: + found = CODE_PATTERN.fullmatch(utterance.upper()) + if not found: + return _say("먼저 홈페이지 관리자 화면의 [내 사이트]에서 카카오톡 연결 코드를 받아 보내 주세요.") + try: + user_id = await link_service.redeem(utterance, channel_user_key) + except KakaoLinkError: + # ★ 없는 코드·만료·시도 초과를 구분해 답하지 않는다(kakao_link_service 주석). + return _say("코드가 맞지 않거나 시간이 지났어요. 새 코드를 받아 다시 보내 주세요.") + + # ★ 연결만 알리고 끝내지 않는다. 사장님은 **어느 홈페이지를 다루는 대화인지** 모른 채 + # 말을 걸게 되고, 가게가 둘 이상이면 첫 마디부터 되묻기에 걸린다. + 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: + return _say("계정을 찾지 못했어요. 관리자 화면에서 다시 연결해 주세요.") + + # ★ 이미 연결된 사람이 코드를 또 보내는 일이 실제로 있었다(2026-09-22). 그대로 두면 + # 6자리가 그냥 발화로 모델에 넘어가 유료 호출 + 대기만 쌓인다 — 여기서 끊는다. + if CODE_PATTERN.fullmatch(utterance.upper()): + return _say("이미 연결되어 있어요. 바로 말씀하시면 됩니다.\n예) 체크인 시간 3시로 바꿔줘") + + # ── 확인 이어받기 ──────────────────────────────────────────────────── + pending = None + if row.pending_tool and row.pending_expires_at and row.pending_expires_at > _now(): + pending = {"tool": row.pending_tool, "args": row.pending_args or {}} + elif row.pending_tool: + # 만료. 조용히 흘리지 않고 치운다 — 남아 있으면 다음 "네" 가 그걸 집는다. + await _clear_pending(channel_user_key) + + if pending is not None: + if utterance in _YES: + await _clear_pending(channel_user_key) + result = await runtime.chat(user, str(row.current_place_id), "", confirm=pending) + return _say(result["reply"]) + if utterance in _NO: + await _clear_pending(channel_user_key) + return _say("알겠습니다. 그대로 두겠습니다.") + # 다른 말을 했으면 그 말이 우선이다. 묵은 확인을 들고 있지 않는다. + await _clear_pending(channel_user_key) + + # ── 가게 고르기 ────────────────────────────────────────────────────── + place_id, ask = await _pick_place(user, row, utterance) + if ask is not None: + return ask + + # ── 도구 ───────────────────────────────────────────────────────────── + try: + result = await runtime.chat(user, place_id, utterance) + except runtime.AgentError as ex: + return _say(_ERRORS.get(str(ex), "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요.")) + + if result.get("needs_confirm") and result.get("tool"): + await _update_link( + channel_user_key, + pending_tool=result["tool"], + pending_args=result.get("args") or {}, + pending_expires_at=_now() + timedelta(minutes=PENDING_MINUTES), + ) + return _say(result["reply"], [CONFIRM_LABEL, DECLINE_LABEL]) + + # 값을 고쳤으면 재발행을 바로 누를 수 있게 바로가기를 붙인다 — 도구가 이미 그렇게 묻는다. + quick = [PUBLISH_LABEL, DECLINE_LABEL] if result.get("done") and result.get("tool") != REGISTRY["publish"].name else [] + if quick: + await _update_link( + channel_user_key, + pending_tool="publish", + pending_args={}, + pending_expires_at=_now() + timedelta(minutes=PENDING_MINUTES), + ) + return _say(result["reply"], quick) + + +_ERRORS = { + "PLACE_NOT_FOUND": "그 가게를 찾지 못했어요.", + "AGENT_NOT_CONFIGURED": "지금은 대화 기능이 꺼져 있어요.", + "AGENT_MESSAGE_TOO_LONG": "말씀이 조금 길어요. 짧게 나눠서 말씀해 주세요.", + "AGENT_CALL_FAILED": "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요.", +} diff --git a/solution/backend/services/agent/runtime.py b/solution/backend/services/agent/runtime.py index 1212698..ee1e352 100644 --- a/solution/backend/services/agent/runtime.py +++ b/solution/backend/services/agent/runtime.py @@ -45,11 +45,10 @@ class AgentError(RuntimeError): def is_configured() -> bool: """대화창을 열 수 있나 — 스위치와 LLM 키를 **둘 다** 본다. - ★ `AGENT_CHAT_ENABLED` 가 기본 꺼짐이다(config/agent_config). 카카오톡 채널이 준비되기 - 전에는 대화창을 띄우지 않는다 — 코드는 다 있지만 사장님 입장에서는 어디에도 닿지 않는 - 입구이고, 열려 있으면 "되는 기능" 으로 오해한다. - 이건 Threads 카드처럼 '자리는 두고 버튼만 죽이는' 경우와 다르다. 저쪽은 사장님이 - **곧 쓸 수 있는** 기능이라 존재를 알려야 했고, 이쪽은 아직 제품이 아니다.""" + ★ 스위치(`AGENT_CHAT_ENABLED`)와 키를 **둘 다** 보는 이유: 키만 보면 "잠시 닫아 두기" 를 + 키를 지워서 해야 하는데 그러면 소개문·사진분류까지 같이 꺼진다. 스위치만 보면 + 키 없는 환경에서 **눌러도 안 되는 입구**가 생긴다. + 실제로 2026-09-21 에 카카오 채널 보류로 한 번 닫았고, 채널 인증이 끝나 다시 열었다.""" return config.chat_enabled() and provider.active().is_configured() @@ -72,14 +71,18 @@ async def _context_facts(user: UserInfo, place_id: str, place) -> list[dict]: res = await FactService(FactCRUD(), PlaceCRUD()).list_facts(user, place_id, publishable_only=True) schema = get_schema(PlaceCategory(place.category)) out = [] - for f in (res.facts or [])[:60]: + for f in (res.facts or []): spec = schema.get(f.key) if spec and spec.scope == "place" and (f.value or "").strip(): - out.append({"key": f.key, "label": spec.label, "value": f.value}) - return out + # ★ label 은 싣지 않는다 — 아래 '항목 목록' 에 이미 key↔label 이 있다. + # 같은 표를 두 번 보내면 프롬프트만 커지고 모델이 얻는 것은 없다. + out.append({f.key: f.value}) + # ★ 상한을 둔다. 실측(2026-09-22): 필드 43 + fact 수십 개가 실린 프롬프트가 5초 벽을 + # 넘겼다. 무한정 싣지 않는다 — 대화 한 턴에 필요한 맥락은 그렇게 많지 않다. + return out[:30] -async def _choose(place, fields, facts, site_line, message) -> dict: +async def _choose(place, fields, facts, message) -> dict: """LLM 한 번. 고른 도구 이름과 인자만 받는다.""" active = provider.active() async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: @@ -91,7 +94,6 @@ async def _choose(place, fields, facts, site_line, message) -> dict: tools=registry.describe(), fields=fields, facts=facts, - site={"요약": site_line}, message=message, ), response_schema=prompt.RESPONSE_SCHEMA, @@ -127,10 +129,11 @@ async def chat(user: UserInfo, place_id: str, message: str, confirm: dict | None fields = registry.fields_of(place) facts = await _context_facts(user, place_id, place) - site_line = await registry.REGISTRY["get_site_status"].run(ctx, {}) + # ★ 사이트 상태는 프롬프트에 싣지 않는다. 그 한 줄 때문에 매 턴 사이트 조회 + 슬러그 계산이 + # 돌았고, 정작 모델이 필요할 때는 `get_site_status` 도구를 부르면 된다. try: - choice = await _choose(place, fields, facts, site_line, message) + choice = await _choose(place, fields, facts, message) except LlmError as ex: LOG.w(f"[agent] 도구 선택 실패: {type(ex).__name__}") raise AgentError("AGENT_CALL_FAILED") from ex diff --git a/solution/backend/services/blog_jobs.py b/solution/backend/services/blog_jobs.py index 23e00f1..b915680 100644 --- a/solution/backend/services/blog_jobs.py +++ b/solution/backend/services/blog_jobs.py @@ -3,7 +3,7 @@ ★ 잡은 '대상을 고르는 것'까지만 하고 실제 일은 서비스가 한다(scheduler/jobs.py 규약). ★ 한 번에 BATCH_SIZE 건씩 만든다. 한 달치를 한 호출로 뽑으면 앞 회차 주제를 프롬프트에 못 넣어 중복이 막히지 않는다. -★ 팀 사전검수 없음 — 금칙 필터(blog_service.filter_drafts)를 통과하면 바로 REVIEWED 로 +★ 팀 사전검수 없음 — 금칙 필터(blog_service.is_publishable_body)를 통과하면 바로 REVIEWED 로 쌓이고, send_reviewed() 가 업장당 하루 한 통씩 그대로 사장님에게 보낸다. ★ 글마다 scheduled_date(KST) 를 하나씩 배정한다 — "언제 만들어졌나"만 있고 "언제 낼 것인가"가 없으면 달력 화면이 근거 없는 날짜를 지어내야 한다(2026-09-17). @@ -73,11 +73,14 @@ async def _pending_count(place_id) -> int: return len(result.all()) if result is not None else 0 -async def _generate_for_place(place) -> int: - """업장 하나. 재고가 이미 REFILL_BELOW 이상이면 아무것도 안 만든다(만든 수 0).""" - if await _pending_count(place.place_id) >= REFILL_BELOW: - return 0 +async def _compose_for_dates(place, dates: list[date]) -> list[dict]: + """날짜마다 그 날짜에 맞는 소재(blog_service.materials(snapshot, d))로 한 편씩 만든다 — + 세 생성 경로(자동·구간·개별)가 같이 쓴다. 저장은 부르는 쪽이 한다. + ★ 날짜를 먼저 정하고 소재를 고른다(2026-09-23). 예전에는 소재 목록을 순서대로 뽑아 날짜에 + 차례로 붙여서, 글 내용이 배정된 날짜와 무관했다. + ★ 그 날짜에 맞는 소재가 없으면 그 날짜만 비워 두고 다음 날짜로 간다 — 뒤 날짜엔 축제가 걸릴 수 있다. + ★ LLM 이 없거나 실패하면(None) 그 자리에서 멈춘다 — 날짜마다 소재를 전부 돌며 헛호출하지 않는다.""" used = await DB_SESSION_MNG.execute_lambda( place_posts.DBType(), DBWRType.DB_READ.value, lambda s, pid=place.place_id: _crud.used_topic_keys(s, pid), @@ -87,39 +90,47 @@ async def _generate_for_place(place) -> int: region = site_payload.region_label(place.road_address, place.address) rows = [] - for kind, key, material in blog_service.materials(snapshot): - if len(rows) >= BATCH_SIZE: + for target in dates: + for kind, key, material in blog_service.materials(snapshot, target): + if key in used_set: + continue + generated = await blog_service.generate_one( + place_name=place.name, region=region, topic_kind=kind, material=material, + used_topics=sorted(used_set), place_category=place.category, post_date=target, + ) + if not generated: + return rows + body, model = generated + ok, reason = blog_service.is_publishable_body(body) + used_set.add(key) # 버린 주제도 이번 회차에서 다시 고르지 않는다 + if not ok: + LOG.i(f"[blog] place={place.place_id} {target} 버림 — {reason}") + continue + rows.append({ + "place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key, + "scheduled_date": target, "generation_meta": {"model": model}, + "status": PostStatus.REVIEWED.value, # 금칙 필터를 이미 통과했다 — 팀 사전검수 없음 + }) break - if key in used_set: - continue - generated = await blog_service.generate_one( - place_name=place.name, region=region, topic_kind=kind, - material=material, used_topics=sorted(used_set), place_category=place.category, - ) - if not generated: - continue - body, model = generated - rows.append({ - "place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key, - "generation_meta": {"model": model}, - }) - used_set.add(key) + return rows - kept, dropped = blog_service.filter_drafts(rows) - if kept: - latest = await DB_SESSION_MNG.execute_lambda( - place_posts.DBType(), DBWRType.DB_READ.value, - lambda s, pid=place.place_id: _crud.max_scheduled_date(s, pid), - ) - next_date = max(latest + timedelta(days=1), _today_kst()) if latest else _today_kst() - for offset, row in enumerate(kept): - row["scheduled_date"] = next_date + timedelta(days=offset) + +async def _generate_for_place(place) -> int: + """업장 하나. 재고가 이미 REFILL_BELOW 이상이면 아무것도 안 만든다(만든 수 0).""" + if await _pending_count(place.place_id) >= REFILL_BELOW: + return 0 + + latest = await DB_SESSION_MNG.execute_lambda( + place_posts.DBType(), DBWRType.DB_READ.value, + lambda s, pid=place.place_id: _crud.max_scheduled_date(s, pid), + ) + next_date = max(latest + timedelta(days=1), _today_kst()) if latest else _today_kst() + rows = await _compose_for_dates(place, [next_date + timedelta(days=i) for i in range(BATCH_SIZE)]) + if rows: await DB_SESSION_MNG.execute_lambda_run( - [place_posts.DBType()], [lambda s, r=kept: _crud.add_many(s, r)], + [place_posts.DBType()], [lambda s, r=rows: _crud.add_many(s, r)], ) - if dropped: - LOG.i(f"[blog] place={place.place_id} 버린 {len(dropped)}건 — {dropped[0][1]}") - return len(kept) + return len(rows) async def generate_drafts() -> int: @@ -135,8 +146,8 @@ async def generate_range(place_id: str, start_date: date, end_date: date) -> dic (2026-09-17, 사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까"). 재고 상한(REFILL_BELOW)을 안 본다 — 개별 생성과 같은 이유로, 직접 고른 구간에 상한 로직이 끼어들 자리가 아니다. 이미 글이 있는 날짜는 LLM 을 부르지 않고 건너뛴다 — - 매번 새로 만들고 유니크 충돌로 버리면 호출만 낭비된다. 소재가 떨어지면(구간 안에서 - 더 만들 topic_key 가 없으면) 그 자리에서 멈춘다 — 못 채운 나머지는 그대로 빈 날짜로 남는다.""" + 매번 새로 만들고 유니크 충돌로 버리면 호출만 낭비된다. 그 날짜에 맞는 소재가 없으면 + 그 날짜는 빈 날짜로 남는다(_compose_for_dates).""" place = None for p, _user in await _published_places(): if str(p.place_id) == str(place_id): @@ -157,38 +168,7 @@ async def generate_range(place_id: str, start_date: date, end_date: date) -> dic if not empty_dates: return {"requested": requested, "created": 0} - used = await DB_SESSION_MNG.execute_lambda( - place_posts.DBType(), DBWRType.DB_READ.value, - lambda s, pid=place.place_id: _crud.used_topic_keys(s, pid), - ) - used_set = set(used or []) - snapshot = await build_snapshot(place) - region = site_payload.region_label(place.road_address, place.address) - - rows = [] - for kind, key, material in blog_service.materials(snapshot): - if len(rows) >= len(empty_dates): - break - if key in used_set: - continue - generated = await blog_service.generate_one( - place_name=place.name, region=region, topic_kind=kind, - material=material, used_topics=sorted(used_set), place_category=place.category, - ) - if not generated: - continue - body, model = generated - ok, _reason = blog_service.is_publishable_body(body) - used_set.add(key) - if not ok: - continue - rows.append({ - "place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key, - "generation_meta": {"model": model}, "status": PostStatus.REVIEWED.value, - }) - - for row, d in zip(rows, empty_dates): - row["scheduled_date"] = d + rows = await _compose_for_dates(place, empty_dates) if rows: await DB_SESSION_MNG.execute_lambda_run( [place_posts.DBType()], [lambda s, r=rows: _crud.add_many(s, r)], @@ -208,39 +188,12 @@ async def generate_one_for_date(place_id: str, target_date: date) -> dict | None if place is None: return None - used = await DB_SESSION_MNG.execute_lambda( - place_posts.DBType(), DBWRType.DB_READ.value, - lambda s, pid=place.place_id: _crud.used_topic_keys(s, pid), - ) - used_set = set(used or []) - snapshot = await build_snapshot(place) - region = site_payload.region_label(place.road_address, place.address) - - for kind, key, material in blog_service.materials(snapshot): - if key in used_set: - continue - generated = await blog_service.generate_one( - place_name=place.name, region=region, topic_kind=kind, - material=material, used_topics=sorted(used_set), place_category=place.category, - ) - if not generated: - continue - body, model = generated - ok, _reason = blog_service.is_publishable_body(body) - if not ok: - used_set.add(key) # 이 주제는 이번 시도에서 다시 고르지 않는다 - continue - - row = { - "place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key, - "scheduled_date": target_date, "generation_meta": {"model": model}, - "status": PostStatus.REVIEWED.value, # 금칙 필터를 이미 통과했다 — 벌크 경로(filter_drafts)와 동일 - } - inserted = await DB_SESSION_MNG.execute_lambda_write( - place_posts.DBType(), lambda s, r=row: _crud.add_one(s, r), - ) - return inserted # None 이면 그 날짜(또는 주제)가 이미 차 있었다 — 다시 시도하지 않는다 - return None + rows = await _compose_for_dates(place, [target_date]) + if not rows: + return None + return await DB_SESSION_MNG.execute_lambda_write( + place_posts.DBType(), lambda s, r=rows[0]: _crud.add_one(s, r), + ) # None 이면 그 날짜(또는 주제)가 이미 차 있었다 — 다시 시도하지 않는다 def _mail_body(*, place_name: str, post, user, origin: str, approve_token: str) -> str: diff --git a/solution/backend/services/blog_service.py b/solution/backend/services/blog_service.py index d96bbfc..49eb017 100644 --- a/solution/backend/services/blog_service.py +++ b/solution/backend/services/blog_service.py @@ -8,9 +8,9 @@ import hashlib import re import secrets -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, timedelta, timezone -from common.enums import PlaceCategory, PostStatus, PostTopicKind +from common.enums import LocalContentType, PlaceCategory, PostStatus, PostTopicKind from common.logger import LOG # 본문 길이 — 회의 확정값(140~150자)에 여유를 둔다. 벗어나면 버린다. @@ -66,12 +66,13 @@ def issue_token() -> tuple[str, str, object]: # 숙소(LODGING) 기본 갈래 규칙 — 업종별 규칙이 없을 때의 폴백이기도 하다. TOPIC_RULES: dict[int, str] = { + # ★ 게시일의 실제 날씨는 모른다(글은 며칠·몇 주 앞서 만든다) — "오늘은 비가 옵니다"라고 단정하게 두지 않는다. PostTopicKind.WEATHER.value: - "오늘의 날씨와 그 날씨에 이 숙소에서 하기 좋은 일을 한 장면으로 적는다.", + "소재로 주어진 날씨인 날, 이 숙소에서 하기 좋은 일을 한 장면으로 적는다. 게시일의 날씨를 단정하지 않는다.", PostTopicKind.FESTIVAL.value: - "주어진 축제 하나를 언급하고, 숙소에서 그곳까지 어떻게 가는지를 걸음 단위로 적는다.", + "주어진 축제 하나를 게시일 기준으로(곧 열리는지, 열리는 중인지) 언급하고, 숙소에서 그곳까지 어떻게 가는지를 걸음 단위로 적는다.", PostTopicKind.SEASON.value: - "지금 절기에 이 지역과 숙소가 어떻게 달라지는지를 적는다.", + "게시일 무렵 절기에 이 지역과 숙소가 어떻게 달라지는지를 적는다.", PostTopicKind.NEARBY.value: "주어진 주변 장소 하나를 손님 시선에서 적는다. 영업시간과 가격은 쓰지 않는다.", PostTopicKind.GUIDE.value: @@ -102,17 +103,36 @@ _RULES = ( "- '최고' '유일' 같은 최상급을 쓰지 않는다.\n" "- 손님에게 말하듯 존댓말로 적는다.\n" "- 아래 '이미 쓴 주제'와 겹치는 소재를 고르지 않는다.\n" + "- 게시일과 맞지 않는 계절·날씨·행사 이야기를 쓰지 않는다.\n" ) +_WEEKDAYS = "월화수목금토일" + + +def season_term(on: date) -> str: + """게시일 → 절기 이름(materials 의 계절 소재와 같은 말). 달로만 가른다.""" + return { + 3: "봄", 4: "봄", 5: "봄", + 6: "초여름", 7: "한여름", 8: "한여름", + 9: "초가을", 10: "늦가을", 11: "늦가을", + 12: "초겨울", 1: "한겨울", 2: "한겨울", + }[on.month] + + +def _date_line(on: date) -> str: + return f"게시일: {on.year}년 {on.month}월 {on.day}일({_WEEKDAYS[on.weekday()]}) · {season_term(on)}\n" + def build_prompt(*, place_name: str, region: str, topic_kind: int, material: str, used_topics: list[str], - place_category: int = PlaceCategory.LODGING.value) -> str: - """갈래 하나에 대한 프롬프트 한 벌. 프롬프트를 두 곳에 적지 않으려고 여기서만 만든다.""" + place_category: int = PlaceCategory.LODGING.value, post_date: date | None = None) -> str: + """갈래 하나에 대한 프롬프트 한 벌. 프롬프트를 두 곳에 적지 않으려고 여기서만 만든다. + post_date 가 있으면 게시일을 알려 준다 — 글이 그 날짜의 계절·행사와 맞게 쓰이도록(2026-09-23).""" used = ", ".join(used_topics[:40]) or "없음" noun = _business_noun(place_category) rules = _topic_rules(place_category) return ( f"{region}에 있는 {noun} '{place_name}'의 짧은 홍보 글을 쓴다.\n" + f"{_date_line(post_date) if post_date else ''}" f"갈래: {rules.get(topic_kind, '')}\n" f"소재: {material}\n" f"이미 쓴 주제: {used}\n\n" @@ -146,7 +166,7 @@ def filter_drafts(rows: list[dict]) -> tuple[list[dict], list[tuple[str, str]]]: async def generate_one(*, place_name: str, region: str, topic_kind: int, material: str, used_topics: list[str], place_category: int = PlaceCategory.LODGING.value, - client=None) -> tuple[str, str] | None: + post_date: date | None = None, client=None) -> tuple[str, str] | None: """(문구, 모델명) 한 쌍. LLM 이 없거나 실패하면 None — 생성 실패가 잡을 죽이지 않는다. 모델명은 생성 이력 화면이 "어느 모델썼는지" 보여주는 데 쓴다(2026-09-17, 사장님 지시). @@ -166,7 +186,8 @@ async def generate_one(*, place_name: str, region: str, topic_kind: int, materia return None prompt = build_prompt(place_name=place_name, region=region, topic_kind=topic_kind, - material=material, used_topics=used_topics, place_category=place_category) + material=material, used_topics=used_topics, place_category=place_category, + post_date=post_date) owns = client is None if owns: import httpx @@ -183,27 +204,68 @@ async def generate_one(*, place_name: str, region: str, topic_kind: int, materia await client.aclose() -def materials(snapshot: dict) -> list[tuple[int, str, str]]: - """(갈래, topic_key, 소재). 소재가 없는 갈래는 아예 만들지 않는다 — 지어내지 않는다.""" - local = snapshot.get("local") or {} +# 축제 글을 시작일 며칠 전부터 낼 수 있나. 끝난 축제는 내지 않는다. +FESTIVAL_LEAD_DAYS = 14 + +# 그 달에 말이 되는 날씨만 소재로 쓴다 — 여름에 "눈인 날" 글이 나가지 않게. +_SKIES = ("맑음", "흐림", "비", "안개") +_SKIES_BY_MONTH = {12: ("눈",), 1: ("눈",), 2: ("눈",), 6: ("소나기",), 7: ("소나기",), 8: ("소나기",)} + + +def _ymd(value) -> date | None: + digits = "".join(ch for ch in str(value or "") if ch.isdigit()) + if len(digits) != 8: + return None + try: + return date(int(digits[:4]), int(digits[4:6]), int(digits[6:])) + except ValueError: + return None + + +def materials(snapshot: dict, on: date) -> list[tuple[int, str, str]]: + """게시일 on 에 맞는 (갈래, topic_key, 소재). 앞에 있을수록 먼저 고른다 — 축제 → 계절 → 주변 → 날씨. + 소재가 없는 갈래는 아예 만들지 않는다 — 지어내지 않는다. + + ★ 날짜에 맞춘다(2026-09-23). 예전에는 날짜와 무관한 한 줄 목록이라, 9월 날짜에 '한겨울' 글이나 + 이미 끝난 축제 글이 붙을 수 있었다. + - 축제: 시작 FESTIVAL_LEAD_DAYS 일 전 ~ 끝나는 날 사이에만. 기간을 모르는 축제는 쓰지 않는다. + - 계절: 게시일의 절기 하나. 키에 연도를 넣어 해마다 한 번씩 다시 쓸 수 있다. + - 날씨: 그 달에 있을 법한 것만. 키에 연·월을 넣어 달마다 다시 쓸 수 있다. + - 주변 장소: 날짜와 무관해 늘 후보다. + ★ 스냅샷의 지역 정보는 원문 행 목록(snapshot["local"]["contents"])이다. 예전 코드는 + site_payload 모양(local.festivals·attractions)을 읽어 축제·주변 소재가 늘 비어 있었다.""" + contents = (snapshot.get("local") or {}).get("contents") or [] + by_type: dict[int, list[dict]] = {} + for row in contents: + if isinstance(row, dict): + by_type.setdefault(row.get("content_type"), []).append(row) out: list[tuple[int, str, str]] = [] - for festival in (local.get("festivals") or [])[:12]: - name = (festival.get("name") or "").strip() + for row in by_type.get(LocalContentType.FESTIVAL.value, []): + body = row.get("body") or {} + name = str(body.get("name") or row.get("title") or "").strip() + start = _ymd(body.get("eventstartdate")) + end = _ymd(body.get("eventenddate")) or start + if not name or start is None or not (start - timedelta(days=FESTIVAL_LEAD_DAYS) <= on <= end): + continue + period = f"{start:%Y.%m.%d}" + (f" ~ {end:%Y.%m.%d}" if end != start else "") + detail = f"{body.get('location') or ''} {str(body.get('overview') or '')[:300]}".strip() + out.append((PostTopicKind.FESTIVAL.value, f"festival:{start.year}:{name}"[:120], + f"{name} (기간 {period}) — {detail}".strip(" —"))) + + term = season_term(on) + out.append((PostTopicKind.SEASON.value, f"season:{on.year}:{term}", term)) + + spots = (by_type.get(LocalContentType.ATTRACTION.value, []) + + by_type.get(LocalContentType.RESTAURANT.value, [])) + for row in spots[:20]: + body = row.get("body") or {} + name = str(body.get("name") or row.get("title") or "").strip() if name: - out.append((PostTopicKind.FESTIVAL.value, f"festival:{name}", - f"{name} — {festival.get('location') or ''} {festival.get('description') or ''}".strip())) + detail = body.get("description") or body.get("overview") or body.get("location") or "" + out.append((PostTopicKind.NEARBY.value, f"nearby:{name}"[:120], f"{name} — {detail}".strip(" —"))) - for spot in ((local.get("attractions") or []) + (local.get("restaurants") or []))[:20]: - name = (spot.get("name") or "").strip() - if name: - out.append((PostTopicKind.NEARBY.value, f"nearby:{name}", - f"{name} — {spot.get('description') or spot.get('address') or ''}".strip())) - - for sky in ("맑음", "흐림", "비", "눈", "안개", "소나기"): - out.append((PostTopicKind.WEATHER.value, f"weather:{sky}", f"{sky}인 날")) - - for term in ("봄", "초여름", "한여름", "초가을", "늦가을", "초겨울", "한겨울"): - out.append((PostTopicKind.SEASON.value, f"season:{term}", term)) + for sky in _SKIES + _SKIES_BY_MONTH.get(on.month, ()): + out.append((PostTopicKind.WEATHER.value, f"weather:{on:%Y-%m}:{sky}", f"{sky}인 날")) return out diff --git a/solution/backend/services/post_service.py b/solution/backend/services/post_service.py index 903e229..c571eb9 100644 --- a/solution/backend/services/post_service.py +++ b/solution/backend/services/post_service.py @@ -15,15 +15,15 @@ from crud.job_crud import JobQueue from crud.place_crud import PlaceCRUD from crud.post_crud import PostCRUD from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import jobs as jobs_table, place_posts, places -from common.enums import DBWRType, ErrorType, JobStatus, JobType, PostStatus +from common.database.model.models import jobs as jobs_table, place_posts, places, sites +from common.enums import DBWRType, ErrorType, JobStatus, JobType, PostStatus, SiteStatus from common.logger import LOG from common.models.gmodel import Res_WebPacketProtocol, UserInfo from common.utils.gtime import GTime from router.v1.site.protocol import ( GenerationBatch, PostData, Res_GenerateNow, Res_GenerateOne, Res_GenerationHistory, Res_MyPosts, ) -from services import blog_jobs, blog_service, social_service +from services import blog_jobs, blog_service, site_payload, social_service from services.job_service import enqueue_job EXPIRED = "처리할 수 없는 링크입니다." @@ -76,7 +76,36 @@ class PostService: return {"success": True, "message": SKIPPED} await self._approve_and_publish(post_id, place_id) - return {"success": True, "message": APPROVED} + return { + "success": True, + "message": APPROVED, + "redirect_url": await self._blog_url(place_id), + } + + async def _blog_url(self, place_id) -> str | None: + """이 업장의 발행된 사이트에서 미니 블로그가 보이는 자리. 승인 확인 화면이 몇 초 + 뒤 여기로 자동 연결한다(2026-09-22, 사장님 지시) — 사장님이 승인만 하고 실제로 + 어디에 올라갔는지 못 찾는 걸 줄인다. 사이트가 없거나 아직 미발행이면 None — + 호출부가 자동 연결 없이 확인 문구만 보여준다.""" + def query(session): + return session.execute( + select(places, sites) + .join(sites, sites.place_id == places.place_id) + .where( + places.place_id == place_id, + places.deleted == False, # noqa: E712 + sites.deleted == False, # noqa: E712 + sites.status == SiteStatus.PUBLISHED.value, + ) + .limit(1) + ) + + result = await DB_SESSION_MNG.execute_lambda(places.DBType(), DBWRType.DB_READ.value, query) + row = result.first() if result is not None else None + if not row: + return None + place, site = row + return f"{site_payload.publish_url(place, site)}#blog" async def _load_place(self, user_info: UserInfo, place_id: str): err_type, place = await DB_SESSION_MNG.execute_lambda( diff --git a/solution/backend/services/prompts/agent.py b/solution/backend/services/prompts/agent.py index 4d3f0af..a1bd76b 100644 --- a/solution/backend/services/prompts/agent.py +++ b/solution/backend/services/prompts/agent.py @@ -36,7 +36,7 @@ RESPONSE_SCHEMA = { } -def build_prompt(*, place_name: str, tools: list[dict], fields: list[dict], facts: list[dict], site: dict, message: str) -> str: +def build_prompt(*, place_name: str, tools: list[dict], fields: list[dict], facts: list[dict], message: str) -> str: """사장님 발화 → 도구 하나. ★ 모호하면 실행하지 말고 되물으라고 명시한다. 티오더가 "유사한 메뉴가 2개 이상이면 @@ -55,14 +55,11 @@ def build_prompt(*, place_name: str, tools: list[dict], fields: list[dict], fact 쓸 수 있는 도구: {json.dumps(tools, ensure_ascii=False, indent=1)} -가게 정보에 쓸 수 있는 항목(set_fact 의 key 는 반드시 이 중 하나다): -{json.dumps(fields, ensure_ascii=False)} +가게 정보에 쓸 수 있는 항목 — `key: 이름` (set_fact 의 key 는 반드시 이 중 하나다): +{chr(10).join(f"{f['key']}: {f['label']}" for f in fields)} 지금 저장된 값: {json.dumps(facts, ensure_ascii=False)} -사이트 상태: -{json.dumps(site, ensure_ascii=False)} - 사장님 요청: {message}''' diff --git a/solution/backend/tests/test_agent_runtime.py b/solution/backend/tests/test_agent_runtime.py index e60298a..2ef663f 100644 --- a/solution/backend/tests/test_agent_runtime.py +++ b/solution/backend/tests/test_agent_runtime.py @@ -227,20 +227,22 @@ def test_읽기_도구는_확인을_요구하지_않는다(): # ── 보류 스위치 ───────────────────────────────────────────────────────── -def test_스위치가_꺼져_있으면_키가_있어도_안_열린다(monkeypatch): - """★ 기본이 꺼짐이다. 카카오톡 채널이 준비되기 전에는 대화창을 띄우지 않는다 — - 코드는 다 있지만 사장님에게는 어디에도 닿지 않는 입구다.""" +def test_스위치와_키를_둘_다_본다(monkeypatch): + """★ 키만 보면 '잠시 닫아 두기' 를 키를 지워서 해야 하고, 그러면 소개문·사진분류까지 + 같이 꺼진다. 스위치만 보면 키 없는 환경에서 **눌러도 안 되는 입구**가 생긴다.""" monkeypatch.setattr(runtime.provider, "active", lambda: SimpleNamespace(is_configured=lambda: True)) monkeypatch.delenv("AGENT_CHAT_ENABLED", raising=False) - assert runtime.is_configured() is False - monkeypatch.setenv("AGENT_CHAT_ENABLED", "1") - assert runtime.is_configured() is True + assert runtime.is_configured() is True # 기본은 켜짐(2026-09-22) monkeypatch.setenv("AGENT_CHAT_ENABLED", "0") - assert runtime.is_configured() is False + assert runtime.is_configured() is False # 스위치로 닫을 수 있다 + + monkeypatch.setenv("AGENT_CHAT_ENABLED", "1") + monkeypatch.setattr(runtime.provider, "active", lambda: SimpleNamespace(is_configured=lambda: False)) + assert runtime.is_configured() is False # 켜 뒀어도 키가 없으면 안 열린다 -async def test_꺼진_동안_대화_요청은_거절된다(client, auth_headers, monkeypatch, db_engine): - monkeypatch.delenv("AGENT_CHAT_ENABLED", raising=False) +async def test_닫아_두면_대화_요청은_거절된다(client, auth_headers, monkeypatch, db_engine): + monkeypatch.setenv("AGENT_CHAT_ENABLED", "0") h, pid = await seed(client, auth_headers) res = await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "안녕"}) assert res.status_code == 409 diff --git a/solution/backend/tests/test_blog_owner.py b/solution/backend/tests/test_blog_owner.py index 42ddcc1..e8d7eef 100644 --- a/solution/backend/tests/test_blog_owner.py +++ b/solution/backend/tests/test_blog_owner.py @@ -140,7 +140,7 @@ async def test_deleting_a_post_frees_its_date_for_regeneration(client, db_engine 바로 다시 생성할 수 있어야 한다.""" from services import blog_service - async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category): + async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): return ("새로 만든 문구입니다. " + BODY, "gemini-test-model") monkeypatch.setattr(blog_service, "generate_one", fake_generate_one) @@ -201,7 +201,7 @@ async def test_generate_now_creates_posts_for_published_site(client, db_engine, 사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까") — 발행된 사이트일 때만.""" from services import blog_service - async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category): + async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model") monkeypatch.setattr(blog_service, "generate_one", fake_generate_one) @@ -241,12 +241,52 @@ async def test_generate_now_creates_posts_for_published_site(client, db_engine, assert all(start <= d <= end for d in dates) +async def test_generate_now_writes_each_post_for_its_own_date(client, db_engine, auth_headers, monkeypatch): + """글마다 배정된 날짜를 게시일로 받아 쓰고, 그 날짜의 절기 소재가 붙는다(2026-09-23, + 사장님 지시: "날짜에 맞는 글이 생성 되도록").""" + from services import blog_service + + calls = [] + + async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): + calls.append((post_date, topic_kind, material)) + return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model") + + monkeypatch.setattr(blog_service, "generate_one", fake_generate_one) + + h = await auth_headers("bloggen18") + place_id = await _place(client, h, name="날짜맞춤펜션") + async with db_engine.begin() as conn: + await conn.execute( + text("INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, :st)"), + {"sid": uuid.uuid4(), "pid": place_id, "dom": f"blog-test-{uuid.uuid4().hex[:8]}", "st": SiteStatus.PUBLISHED.value}, + ) + winter = date(date.today().year + 1, 1, 10) + + res = await client.post( + f"/v1/place/{place_id}/post/generate", headers=h, + params={"start": winter.isoformat(), "end": winter.isoformat()}, + ) + + assert res.json()["created"] == 1 + assert calls[0][0] == winter + # 지역 소재가 없는 업장이라 첫 후보는 그 날짜의 절기다. + assert calls[0][1] == PostTopicKind.SEASON.value + assert calls[0][2] == "한겨울" + async with db_engine.begin() as conn: + row = (await conn.execute( + text("SELECT scheduled_date, topic_key FROM place_posts WHERE place_id = :pid"), {"pid": place_id}, + )).one() + assert row[0] == winter + assert row[1] == f"season:{winter.year}:한겨울" + + async def test_generate_now_does_not_append_a_publish_link(client, db_engine, auth_headers, monkeypatch): """미니 블로그에 올라가는 글에는 링크를 붙이지 않는다(2026-09-21, 사장님 지시). site_payload.publish_url() 자체는 남겨둔다 — 나중에 쓰레드 연동에서 따로 쓸 수 있게.""" from services import blog_service - async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category): + async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): return (f"테스트로 만든 문구입니다. {BODY}", "gemini-test-model") monkeypatch.setattr(blog_service, "generate_one", fake_generate_one) @@ -599,7 +639,7 @@ async def test_generate_now_is_noop_for_site_without_domain(client, db_engine, a 실제로 성공하도록 목킹해 둬야 "그냥 LLM 이 설정 안 돼서 0건"과 구분된다.""" from services import blog_service - async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category): + async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model") monkeypatch.setattr(blog_service, "generate_one", fake_generate_one) @@ -720,7 +760,7 @@ async def test_generation_history_counts_by_batch(client, db_engine, auth_header 생성했는지" / "어느 모델썼는지 등등" → JSONB 한 칸(generation_meta)에 담는다).""" from services import blog_service - async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category): + async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model") monkeypatch.setattr(blog_service, "generate_one", fake_generate_one) @@ -819,7 +859,7 @@ async def test_generate_one_fills_a_specific_empty_date(client, db_engine, auth_ """사장님 지시: "개별적으로 새로 만들수있게 해줘" — 달력에서 빈 날짜 하나만 콕 집어 채운다.""" from services import blog_service - async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category): + async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model") monkeypatch.setattr(blog_service, "generate_one", fake_generate_one) @@ -847,7 +887,7 @@ async def test_generate_one_fails_when_date_already_taken(client, db_engine, aut """이미 그 날짜에 글이 있으면(유니크 충돌) 조용히 덮지 않고 실패로 답한다.""" from services import blog_service - async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category): + async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model") monkeypatch.setattr(blog_service, "generate_one", fake_generate_one) diff --git a/solution/backend/tests/test_blog_post.py b/solution/backend/tests/test_blog_post.py index d2e18bd..1bc97ab 100644 --- a/solution/backend/tests/test_blog_post.py +++ b/solution/backend/tests/test_blog_post.py @@ -10,12 +10,12 @@ 예전처럼 "GET 은 확인 화면만" 을 기대하는 테스트를 두지 않는다. """ import uuid -from datetime import timezone +from datetime import date, timezone from sqlalchemy import text -from common.enums import JobType, PlaceCategory, PostStatus, PostTopicKind -from services import blog_service +from common.enums import JobType, LocalContentType, PlaceCategory, PostStatus, PostTopicKind, SiteStatus +from services import blog_service, site_payload BODY = ( "비가 한 차례 지나간 뒤 마당 돌이 검게 젖었습니다. 이런 날에는 대청마루에 앉아 빗소리만 들어도 " @@ -51,6 +51,101 @@ def test_filter_drops_same_topic_twice_in_one_batch(): assert "주제 중복" in dropped[0][1] +def test_build_prompt_falls_back_to_stay_wording_for_unmapped_category(): + """업종별로 분기할 수 있는 구조만 갖춘다 — 숙소 외 업종은 아직 실구현 전이라 숙소 문구로 대체한다.""" + stay = blog_service.build_prompt( + place_name="테스트펜션", region="군산", topic_kind=PostTopicKind.WEATHER.value, + material="맑음인 날", used_topics=[], place_category=PlaceCategory.LODGING.value, + ) + restaurant = blog_service.build_prompt( + place_name="테스트식당", region="군산", topic_kind=PostTopicKind.WEATHER.value, + material="맑음인 날", used_topics=[], place_category=PlaceCategory.RESTAURANT.value, + ) + assert "숙소" in stay + assert "숙소" in restaurant # 음식점 전용 문구가 아직 없어 숙소 문구로 폴백한다 + + +def _snapshot_with(*rows): + return {"local": {"region_code": "52130", "contents": list(rows)}} + + +def _festival_row(name, start, end): + return {"content_type": LocalContentType.FESTIVAL.value, "title": name, + "body": {"name": name, "eventstartdate": start, "eventenddate": end, "location": "군산 원도심"}} + + +def test_materials_follow_the_post_date(): + """게시일에 맞는 소재만 나온다(2026-09-23) — 9월 글에 '한겨울'·'눈'·끝난 축제가 붙지 않는다.""" + snapshot = _snapshot_with( + _festival_row("시간여행축제", "20261002", "20261004"), # 곧 열린다(9일 뒤) + _festival_row("벚꽃축제", "20260401", "20260405"), # 이미 끝났다 + _festival_row("겨울빛축제", "20261220", "20261231"), # 아직 한참 멀다 + {"content_type": LocalContentType.ATTRACTION.value, "title": "경암동 철길마을", + "body": {"name": "경암동 철길마을", "description": "철길 옆 골목"}}, + ) + + out = blog_service.materials(snapshot, date(2026, 9, 23)) + keys = [key for _kind, key, _material in out] + + assert keys[0] == "festival:2026:시간여행축제" + assert "2026.10.02 ~ 2026.10.04" in out[0][2] + assert not any("벚꽃" in k or "겨울빛" in k for k in keys) + assert "season:2026:초가을" in keys + assert not any(k.startswith("season:") and not k.endswith("초가을") for k in keys) + assert "nearby:경암동 철길마을" in keys + assert "weather:2026-09:비" in keys + assert not any(k.endswith(":눈") or k.endswith(":소나기") for k in keys) + + +def test_materials_allow_snow_in_winter_and_reuse_seasons_each_year(): + winter = [key for _k, key, _m in blog_service.materials(_snapshot_with(), date(2027, 1, 10))] + + assert "weather:2027-01:눈" in winter + assert "season:2027:한겨울" in winter + + +def test_build_prompt_tells_the_post_date(): + prompt = blog_service.build_prompt( + place_name="테스트펜션", region="군산", topic_kind=PostTopicKind.SEASON.value, + material="초가을", used_topics=[], post_date=date(2026, 9, 23), + ) + + assert "게시일: 2026년 9월 23일(수) · 초가을" in prompt + + +async def test_generate_one_forwards_place_category_into_the_prompt(monkeypatch): + """generate_one 은 place_category 를 build_prompt 로 그대로 넘긴다 — 링크는 여기서 + 붙이지 않는다(호출부의 길이 게이트가 이 반환값에 그대로 걸리기 때문에, 붙이면 + 게이트가 링크까지 세어 정상 문구도 버려진다). 공급자는 provider.active() 를 통해 + 고르므로(Gemini 로 고정하지 않는다) 가짜 공급자로 검증한다.""" + from services.llm import provider + from services.llm.types import LlmResult, Usage + + captured = {} + + class FakeLlm: + DEFAULT_MODEL = "fake-model" + + @staticmethod + def is_configured(): + return True + + @staticmethod + async def generate(client, model, *, prompt, temperature=0.2, **kwargs): + captured["prompt"] = prompt + return LlmResult(json=None, text=BODY, usage=Usage()) + + monkeypatch.setattr(provider, "active", lambda: FakeLlm) + + result = await blog_service.generate_one( + place_name="테스트식당", region="군산", topic_kind=PostTopicKind.WEATHER.value, + material="맑음인 날", used_topics=[], place_category=PlaceCategory.RESTAURANT.value, + ) + + assert result == (BODY, "fake-model") + assert "숙소" in captured["prompt"] # 음식점 전용 문구가 아직 없어 숙소 문구로 폴백한다 + + async def _seed_post(db_engine, owner_id, *, status=PostStatus.SENT, expires_days=14): place_id, post_id = uuid.uuid4(), uuid.uuid4() token, token_hash, expires = blog_service.issue_token() @@ -94,6 +189,33 @@ async def test_get_approves_immediately(client, db_engine, owner_id): assert res.status_code == 200 assert "올렸습니다" in res.text assert await _status(db_engine, post_id) == PostStatus.APPROVED.value + # 발행된 사이트가 없으면(이 테스트) 자동 이동 없이 확인 문구만 보여준다. + assert "meta http-equiv=\"refresh\"" not in res.text + + +async def test_approve_page_redirects_to_the_published_blog_section(client, db_engine, owner_id): + """승인 확인 화면은 5초 뒤 그 업장의 발행된 사이트(미니 블로그 자리)로 자동 이동한다 + (2026-09-22, 사장님 지시: "이메일 승인후에 블로그가 올라가는 페이지에 5초 정도 + 이후에 연결되도록"). 자동 이동을 못 믿어도 되게 같은 주소를 링크로도 남긴다.""" + token, post_id = await _seed_post(db_engine, owner_id) + async with db_engine.begin() as conn: + place_id = ( + await conn.execute(text("SELECT place_id FROM place_posts WHERE post_id=:p"), {"p": post_id}) + ).scalar_one() + await conn.execute( + text( + "INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, :st)" + ), + {"sid": uuid.uuid4(), "pid": place_id, "dom": "blog-redirect-test", "st": SiteStatus.PUBLISHED.value}, + ) + + res = await client.get(f"/v1/site/post/approve?t={token}") + + assert res.status_code == 200 + assert "올렸습니다" in res.text + expected_url = site_payload.publish_origin() + "/s/blog-redirect-test#blog" + assert f'' in res.text + assert f"href='{expected_url}'" in res.text async def test_approve_link_is_single_use(client, db_engine, owner_id): diff --git a/solution/backend/tests/test_kakao_webhook.py b/solution/backend/tests/test_kakao_webhook.py new file mode 100644 index 0000000..80a8676 --- /dev/null +++ b/solution/backend/tests/test_kakao_webhook.py @@ -0,0 +1,372 @@ +"""카카오톡 채널 웹훅. + +여기서 지키는 것 셋: + 1. 시크릿 없는 요청은 아무것도 하지 못한다 — 오픈빌더가 서명을 주지 않으므로 이게 유일한 문이다 + 2. 연결되지 않은 발화자는 어떤 사장님도 되지 못한다 + 3. 확인(SEMI)은 **만료되면 안 먹는다** — 한참 뒤의 "네" 한 마디에 묵은 발행이 돌면 안 된다 +""" + +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy import text + +from services import kakao_link_service as link_service +from services.agent import channel, runtime + +SECRET = "test-webhook-secret-0123456789" +PATH = "/v1/agent/kakao/webhook" + + +@pytest.fixture(autouse=True) +def secret(monkeypatch): + monkeypatch.setenv("KAKAO_WEBHOOK_SECRET", SECRET) + monkeypatch.setenv("KAKAO_CHANNEL_PUBLIC_ID", "_testCh") + monkeypatch.setenv("AGENT_CHAT_ENABLED", "1") + monkeypatch.delenv("KAKAO_BOT_ID", raising=False) + + +def body(utterance, speaker="kakao-speaker-1", bot_id="bot-1"): + return { + "userRequest": {"utterance": utterance, "user": {"id": speaker, "type": "botUserKey"}}, + "bot": {"id": bot_id, "name": "ADO2"}, + } + + +def said(res) -> str: + return res.json()["template"]["outputs"][0]["simpleText"]["text"] + + +def quick(res) -> list: + return [q["label"] for q in res.json()["template"].get("quickReplies", [])] + + +async def owner_with_place(client, auth_headers, name="대화숙소"): + h = await auth_headers(f"kakao-{uuid.uuid4().hex[:8]}") + res = await client.post("/v1/place", headers=h, json={"name": name, "category": 1}) + return h, res.json()["place"]["place_id"] + + +async def user_id_of(db_engine, place_id): + async with db_engine.begin() as c: + return ( + await c.execute(text("SELECT owner_user_id FROM places WHERE place_id=:p"), {"p": uuid.UUID(place_id)}) + ).scalar_one() + + +async def link(db_engine, client, auth_headers, speaker, name="대화숙소"): + """사장님 하나 + 가게 하나 + 그 사장님에 묶인 카톡 발화자.""" + h, pid = await owner_with_place(client, auth_headers, name) + uid = await user_id_of(db_engine, pid) + await link_service.redeem((await link_service.issue_code(uid))["code"], speaker) + return h, pid, uid + + +# ── 1. 시크릿이 유일한 문이다 ──────────────────────────────────────────── + +async def test_시크릿이_없으면_존재를_알리지_않는다(client, monkeypatch): + """★ 401 이 아니라 404 다. 401 은 '여기 뭔가 있다' 를 알려 준다.""" + monkeypatch.setenv("KAKAO_WEBHOOK_SECRET", "") + assert (await client.post(PATH, json=body("안녕"))).status_code == 404 + + +async def test_틀린_시크릿도_404(client): + res = await client.post(PATH, headers={"X-Agent-Secret": "wrong-secret"}, json=body("안녕")) + assert res.status_code == 404 + res = await client.post(PATH, json=body("안녕")) # 헤더 없음 + assert res.status_code == 404 + + +async def test_경로_시크릿도_받는다(client, db_engine): + res = await client.post(f"{PATH}/{SECRET}", json=body("안녕")) + assert res.status_code == 200 + res = await client.post(f"{PATH}/wrong-secret", json=body("안녕")) + assert res.status_code == 404 + + +async def test_다른_봇의_요청은_거절한다(client, monkeypatch, db_engine): + monkeypatch.setenv("KAKAO_BOT_ID", "bot-1") + ok = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("안녕", bot_id="bot-1")) + assert ok.status_code == 200 + bad = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("안녕", bot_id="남의봇")) + assert bad.status_code == 404 + + +# ── 2. 연결되지 않은 발화자 ────────────────────────────────────────────── + +async def test_연결_전에는_어떤_도구도_돌지_않는다(client, monkeypatch, db_engine): + called = AsyncMock() + monkeypatch.setattr(runtime, "chat", called) + res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("체크인 3시로 바꿔줘", "모르는-키")) + assert res.status_code == 200 + assert "연결" in said(res) + called.assert_not_awaited() + + +async def test_발화자_id_를_위조해도_남의_가게에_닿지_않는다(client, auth_headers, db_engine, monkeypatch): + """★ 신원 연결이 없으면 카톡 진입점만 소유자 범위 밖에 놓인다 — 그걸 막는 자리다.""" + await link(db_engine, client, auth_headers, "진짜-사장님-키") + called = AsyncMock() + monkeypatch.setattr(runtime, "chat", called) + res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("정보 보여줘", "위조한-키")) + assert "연결" in said(res) + called.assert_not_awaited() + + +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 await link_service.resolve("새-발화자") == uid + + +async def test_틀린_코드는_이유를_구분해_말하지_않는다(client, db_engine): + res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("ZZZZZZ", "새-발화자2")) + assert "코드가 맞지 않거나" in said(res) + + +# ── 3. 확인은 만료되면 안 먹는다 ───────────────────────────────────────── + +async def test_발행은_묻고_바로가기를_준다(client, auth_headers, db_engine, monkeypatch): + speaker = "확인-테스트-키" + await link(db_engine, client, auth_headers, speaker) + # 테스트는 실제 모델을 부르지 않는다 — 런타임만 열고 선택 결과를 대신 준다. + monkeypatch.setattr(runtime, "is_configured", lambda: True) + monkeypatch.setattr(runtime, "_choose", AsyncMock(return_value={"tool": "publish", "args": {}, "message": ""})) + + res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("발행해줘", speaker)) + assert channel.CONFIRM_LABEL in quick(res) + async with db_engine.begin() as c: + pending = ( + await c.execute(text("SELECT pending_tool FROM owner_kakao_links WHERE channel_user_key=:k"), {"k": speaker}) + ).scalar_one() + assert pending == "publish" + + +async def test_만료된_확인에_네_라고_해도_실행되지_않는다(client, auth_headers, db_engine, monkeypatch): + """★ 이게 없으면 한참 뒤의 '네' 한 마디에 **묵은 발행**이 돈다.""" + speaker = "만료-테스트-키" + await link(db_engine, client, auth_headers, speaker) + async with db_engine.begin() as c: + await c.execute( + text("""UPDATE owner_kakao_links + SET pending_tool='publish', pending_args='{}'::jsonb, + pending_expires_at = now() - interval '1 minute' + WHERE channel_user_key=:k"""), + {"k": speaker}, + ) + ran = AsyncMock() + monkeypatch.setattr(runtime, "chat", ran) + await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("네", speaker)) + # 확인으로 실행된 적이 없다(다른 말로 취급돼 일반 경로로 갔을 수는 있다). + for call in ran.await_args_list: + assert call.kwargs.get("confirm") is None + + +async def test_바로가기_라벨은_예로_읽히는_말에_들어_있다(): + """★ 라벨과 _YES 가 어긋나면 **눌러도 안 먹는다** — 사장님은 버튼이 고장난 줄 안다.""" + assert channel.CONFIRM_LABEL in channel._YES + assert channel.PUBLISH_LABEL in channel._YES + assert channel.DECLINE_LABEL in channel._NO + + +# ── 가게 고르기 ────────────────────────────────────────────────────────── + +async def test_가게가_여럿이면_추측하지_않고_되묻는다(client, auth_headers, db_engine, monkeypatch): + """★ 임의로 첫 가게를 고르면 사장님은 엉뚱한 가게를 고쳐 놓고도 모른다.""" + 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) + res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("정보 보여줘", speaker)) + assert "어느 가게" 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 with db_engine.begin() as c: + current = ( + await c.execute( + text("SELECT current_place_id FROM owner_kakao_links WHERE channel_user_key=:k"), {"k": speaker} + ) + ).scalar_one() + assert current is not None + + +# ── 5초 벽 · 실패 ──────────────────────────────────────────────────────── + +async def test_느리면_침묵_대신_안내로_끊는다(client, monkeypatch, db_engine): + """넘기면 카카오가 연결을 끊는다 — 사장님에게는 말없이 실패하는 봇이 된다.""" + import router.v1.agent.kakao_bot as bot + + async def slow(*_a, **_kw): + import asyncio + + await asyncio.sleep(1) + + monkeypatch.setattr(bot, "DEADLINE_SEC", 0.01) + monkeypatch.setattr(bot.channel, "handle", slow) + res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("안녕")) + assert res.status_code == 200 + assert "잠시 뒤" in said(res) + + +async def test_내부_오류도_200_으로_답한다(client, monkeypatch, db_engine): + """메신저에서는 500 도 침묵으로 보인다 — 무슨 일이 있었는지 한 줄은 말해야 한다.""" + import router.v1.agent.kakao_bot as bot + + monkeypatch.setattr(bot.channel, "handle", AsyncMock(side_effect=RuntimeError("어딘가 터짐"))) + res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("안녕")) + assert res.status_code == 200 + assert "어딘가 터짐" not in res.text + + +async def test_발화자가_없으면_거기서_끝낸다(client, db_engine): + res = await client.post( + PATH, headers={"X-Agent-Secret": SECRET}, json={"userRequest": {"utterance": "안녕", "user": {}}} + ) + assert res.status_code == 200 + assert "확인하지 못했" in said(res) + + +# ── 응답 형식 ──────────────────────────────────────────────────────────── + +def test_카카오_형식은_이_파일_밖으로_나가지_않는다(): + """★ 서비스 계층에 새면 다른 채널을 붙일 때 전부 걷어내야 한다.""" + import ast + import inspect + + # ★ 주석·docstring 에 이름이 나오는 것은 '샌' 것이 아니다 — 실제 코드만 본다. + tree = ast.parse(inspect.getsource(channel)) + for node in ast.walk(tree): + if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str): + node.value.value = "" # docstring 비우기 + dumped = ast.dump(tree) + for token in ("simpleText", "quickReplies", "userRequest", "2.0"): + assert token not in dumped, token + + +def test_바로가기는_열_개를_넘기지_않는다(): + import router.v1.agent.kakao_bot as bot + + out = bot._reply("안녕", [f"라벨{i}" for i in range(20)]) + 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) + + +# ── 콜백 (5초 벽 넘기) ─────────────────────────────────────────────────── + +async def test_콜백이_켜져_있으면_즉답하고_뒤에서_마저_만든다(client, auth_headers, db_engine, monkeypatch): + """★ 오픈빌더 스킬 타임아웃은 5초다. 콜백을 쓰면 '잠시만 기다려 주세요' 를 먼저 주고 + 답이 완성되면 그 주소로 따로 보낸다 — 느린 답이 침묵이 되지 않는다.""" + import router.v1.agent.kakao_bot as bot + + sent = {} + + async def fake_push(callback_url, utterance, speaker): + sent["url"] = callback_url + sent["payload"] = await bot._answer(utterance, speaker, 5.0) + + monkeypatch.setattr(bot, "_push", fake_push) + + payload = body("안녕", "콜백-발화자") + payload["userRequest"]["callbackUrl"] = "https://callback.example/one-shot" + res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=payload) + + # 즉답은 useCallback 이다 — template 을 싣지 않는다. + assert res.json()["useCallback"] is True + assert res.json()["data"]["text"] + assert "template" not in res.json() + + # 실제 답은 콜백으로 간다. + assert sent["url"] == "https://callback.example/one-shot" + assert "연결" in sent["payload"]["template"]["outputs"][0]["simpleText"]["text"] + + +async def test_콜백이_없으면_예전처럼_동기로_답한다(client, db_engine): + res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("안녕", "동기-발화자")) + assert "useCallback" not in res.json() + assert res.json()["template"]["outputs"] + + +async def test_콜백_전송이_실패해도_터지지_않는다(monkeypatch): + """★ 주소는 1분 · 1회다. 재시도하지 않는다 — 두 번째 POST 는 어차피 거절된다.""" + import router.v1.agent.kakao_bot as bot + + monkeypatch.setattr(bot, "_answer", AsyncMock(return_value=bot._reply("답"))) + + class Boom: + async def __aenter__(self): + raise RuntimeError("네트워크 끊김") + + async def __aexit__(self, *_): + return False + + monkeypatch.setattr(bot.httpx, "AsyncClient", lambda **_kw: Boom()) + await bot._push("https://callback.example/x", "안녕", "누구") # 예외가 새 나오지 않는다