Agent 2.0 exdev 서버 배포 스택
- server-dev start/stop/deploy 및 Gitea push 자동 배포 - local-dev 로컬 개발 환경 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
HELP.md
|
||||
.gradle
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
bin/
|
||||
!**/src/main/**/bin/
|
||||
!**/src/test/**/bin/
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
out/
|
||||
!**/src/main/**/out/
|
||||
!**/src/test/**/out/
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
@@ -0,0 +1,359 @@
|
||||
# 카카오 챗봇 Callback 방식 구현 정리
|
||||
|
||||
## 📋 구현 개요
|
||||
|
||||
폴백 블록에서 RAG API 호출 시 5초 타임아웃 문제를 해결하기 위해 **카카오 Callback 방식**을 구현했습니다.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 문제 원인
|
||||
|
||||
### **카카오 i 오픈빌더 타임아웃 제한**
|
||||
- **스킬 서버 응답 제한**: 5초
|
||||
- **RAG API 응답 시간**: 최대 7초 이상 소요
|
||||
- **결과**: 타임아웃 발생으로 사용자에게 응답이 표시되지 않음
|
||||
|
||||
### **로그 분석 결과**
|
||||
```
|
||||
❌ 표시되지 않는 케이스 (타이어펑크):
|
||||
18:47:28.094 - RAG API 호출 시작
|
||||
18:47:35.237 - RAG API 응답 수신
|
||||
→ 응답 시간: 7.14초 (5초 초과)
|
||||
|
||||
✅ 정상 표시되는 케이스 (주식):
|
||||
18:48:29.097 - RAG API 호출 시작
|
||||
18:48:29.219 - RAG API 응답 수신
|
||||
→ 응답 시간: 0.12초 (5초 이내)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 해결 방법: Callback 방식
|
||||
|
||||
### **2단계 프로세스**
|
||||
|
||||
#### **1단계: 즉시 응답 (5초 내)**
|
||||
```json
|
||||
{
|
||||
"version": "2.0",
|
||||
"useCallback": true,
|
||||
"template": {
|
||||
"outputs": [
|
||||
{
|
||||
"simpleText": {
|
||||
"text": "답변을 생성하고 있습니다. 잠시만 기다려주세요..."
|
||||
}
|
||||
}
|
||||
],
|
||||
"quickReplies": [ ... ]
|
||||
}
|
||||
}
|
||||
```
|
||||
- `useCallback: true` 설정으로 비동기 처리 선언
|
||||
- 사용자에게 "처리중" 메시지 즉시 표시
|
||||
|
||||
#### **2단계: Callback 응답 (시간 제한 없음)**
|
||||
```json
|
||||
{
|
||||
"version": "2.0",
|
||||
"template": {
|
||||
"outputs": [
|
||||
{
|
||||
"simpleText": {
|
||||
"text": "최종 답변 내용..."
|
||||
}
|
||||
}
|
||||
],
|
||||
"quickReplies": [ ... ]
|
||||
}
|
||||
}
|
||||
```
|
||||
- RAG API 처리 완료 후 `callbackUrl`로 POST
|
||||
- 사용자 화면의 "처리중" 메시지가 최종 답변으로 교체
|
||||
|
||||
---
|
||||
|
||||
## 🔧 구현 내용
|
||||
|
||||
### **1. handleFallbackWithAiServer() - 메인 메서드**
|
||||
|
||||
```java
|
||||
@KakaoIntent("폴백 블록")
|
||||
public SkillResponse handleFallbackWithAiServer(KakaoRequest req) {
|
||||
// callbackUrl 추출
|
||||
String callbackUrl = req.getUserRequest().getCallbackUrl();
|
||||
|
||||
if (callbackUrl != null && !callbackUrl.isEmpty()) {
|
||||
// Callback 방식: 비동기 처리
|
||||
- 즉시 "처리중" 응답 반환 (useCallback: true)
|
||||
- CompletableFuture로 백그라운드 처리
|
||||
return immediateResponse;
|
||||
} else {
|
||||
// callbackUrl 없음: 동기 처리 (5초 제한)
|
||||
return processRagSync(...);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**특징:**
|
||||
- callbackUrl 유무에 따라 자동으로 처리 방식 선택
|
||||
- 관리자 모드 테스트(callbackUrl 없음) ⟶ 동기 처리
|
||||
- 실제 사용자(callbackUrl 있음) ⟶ Callback 처리
|
||||
|
||||
---
|
||||
|
||||
### **2. processRagAndSendCallback() - 비동기 처리**
|
||||
|
||||
```java
|
||||
private void processRagAndSendCallback(String userInput, String userId,
|
||||
String callbackUrl, List<QuickReply> quickReplies) {
|
||||
try {
|
||||
// RAG API 호출 (시간 제한 없음)
|
||||
String answer = callRagApi(userInput, userId);
|
||||
|
||||
// Callback으로 최종 답변 전송
|
||||
sendCallback(callbackUrl, answer, quickReplies);
|
||||
|
||||
} catch (Exception e) {
|
||||
// 오류 시에도 Callback으로 에러 메시지 전송
|
||||
sendCallback(callbackUrl, "에러 메시지", quickReplies);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**실행 방식:**
|
||||
- `CompletableFuture.runAsync()` 사용
|
||||
- 별도 스레드에서 실행 (메인 스레드 블로킹 없음)
|
||||
- 오류 발생 시에도 사용자에게 에러 메시지 전송
|
||||
|
||||
---
|
||||
|
||||
### **3. sendCallback() - Callback 전송**
|
||||
|
||||
```java
|
||||
private void sendCallback(String callbackUrl, String answer, List<QuickReply> quickReplies) {
|
||||
// Callback 응답 구성
|
||||
SkillResponse callbackResponse = new SkillResponse();
|
||||
callbackResponse.setVersion("2.0");
|
||||
// useCallback 설정 안 함 (최종 응답)
|
||||
callbackResponse.setTemplate(...);
|
||||
|
||||
// callbackUrl로 POST
|
||||
restTemplate.postForEntity(callbackUrl, callbackResponse, String.class);
|
||||
}
|
||||
```
|
||||
|
||||
**중요:**
|
||||
- `useCallback`을 설정하지 않음 (또는 false)
|
||||
- 카카오가 제공한 callbackUrl로 POST
|
||||
|
||||
---
|
||||
|
||||
### **4. processRagSync() - 동기 처리 (Fallback)**
|
||||
|
||||
```java
|
||||
private SkillResponse processRagSync(String userInput, String userId,
|
||||
List<QuickReply> quickReplies) {
|
||||
// 동기 방식으로 RAG API 호출 (5초 제한 있음)
|
||||
String answer = callRagApi(userInput, userId);
|
||||
|
||||
// 즉시 응답 반환
|
||||
return skillResponse.getResult(outputs, quickReplies);
|
||||
}
|
||||
```
|
||||
|
||||
**용도:**
|
||||
- callbackUrl이 없을 때 (관리자 모드 테스트 등)
|
||||
- 기존 방식과 동일
|
||||
|
||||
---
|
||||
|
||||
### **5. callRagApi() - RAG API 호출 (공통 메서드)**
|
||||
|
||||
```java
|
||||
private String callRagApi(String userInput, String userId) throws Exception {
|
||||
// RAG API 호출
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("question", userInput);
|
||||
body.put("botId", userId);
|
||||
|
||||
Map<String, Object> res = restTemplate.postForObject(
|
||||
CHATBOT_API + "v1/rag-only", entity, Map.class);
|
||||
|
||||
// 응답 파싱 및 줄바꿈 정리
|
||||
String answer = ...;
|
||||
answer = answer.replaceAll(" \\n", "\n"); // 마크다운 줄바꿈
|
||||
answer = answer.replaceAll("\\n{3,}", "\n\n"); // 연속 줄바꿈 제한
|
||||
answer = answer.replaceAll(" *\\n *", "\n"); // 공백 제거
|
||||
|
||||
return answer;
|
||||
}
|
||||
```
|
||||
|
||||
**중복 코드 제거:**
|
||||
- 동기/비동기 모두 이 메서드 사용
|
||||
- 줄바꿈 정리 로직 포함
|
||||
|
||||
---
|
||||
|
||||
## 🚀 동작 흐름
|
||||
|
||||
### **Callback 방식 (실제 사용자)**
|
||||
|
||||
```
|
||||
사용자: "타이어펑크"
|
||||
↓
|
||||
카카오 → 스킬서버
|
||||
- callbackUrl 포함
|
||||
↓
|
||||
스킬서버: 즉시 응답 (0.1초)
|
||||
- "답변을 생성하고 있습니다..."
|
||||
- useCallback: true
|
||||
↓
|
||||
카카오 → 사용자
|
||||
- "답변을 생성하고 있습니다..." 표시
|
||||
↓
|
||||
[백그라운드 처리]
|
||||
스킬서버 → RAG API (7초 소요)
|
||||
↓
|
||||
스킬서버 → callbackUrl
|
||||
- 최종 답변 POST
|
||||
↓
|
||||
카카오: 메시지 교체
|
||||
- "답변을 생성하고 있습니다..."
|
||||
→ "타이어 펑크 보상 안내..."
|
||||
↓
|
||||
사용자: 최종 답변 확인
|
||||
```
|
||||
|
||||
### **동기 방식 (관리자 테스트)**
|
||||
|
||||
```
|
||||
관리자: "주식"
|
||||
↓
|
||||
스킬서버: callbackUrl 없음 확인
|
||||
↓
|
||||
동기 방식 처리
|
||||
- RAG API 호출 (0.12초)
|
||||
↓
|
||||
즉시 응답 반환
|
||||
- "죄송합니다. 준비된 답이 없습니다..."
|
||||
↓
|
||||
관리자: 답변 확인
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 테스트 결과
|
||||
|
||||
### **빌드 성공**
|
||||
```bash
|
||||
> .\gradlew.bat build -x test
|
||||
|
||||
BUILD SUCCESSFUL in 1s
|
||||
5 actionable tasks: 3 executed, 2 up-to-date
|
||||
```
|
||||
|
||||
### **컴파일 성공**
|
||||
- 문법 오류 없음
|
||||
- 린트 warning은 기존 코드에서 발생한 것
|
||||
|
||||
---
|
||||
|
||||
## ✅ 적용된 개선 사항
|
||||
|
||||
### **1. 5초 타임아웃 해결**
|
||||
- ✅ Callback 방식으로 시간 제한 없음
|
||||
- ✅ RAG API가 10초 걸려도 정상 처리
|
||||
|
||||
### **2. 사용자 경험 개선**
|
||||
- ✅ "처리중" 메시지로 대기 안내
|
||||
- ✅ 답변 생성 완료 시 자동 업데이트
|
||||
|
||||
### **3. 안정성 향상**
|
||||
- ✅ callbackUrl 없을 때 동기 처리로 자동 폴백
|
||||
- ✅ 오류 시에도 Callback으로 에러 메시지 전송
|
||||
|
||||
### **4. 코드 구조 개선**
|
||||
- ✅ RAG API 호출 로직 공통 메서드로 분리
|
||||
- ✅ 비동기/동기 처리 명확히 구분
|
||||
- ✅ 상세한 로그로 디버깅 용이
|
||||
|
||||
### **5. 줄바꿈 호환성**
|
||||
- ✅ 마크다운 줄바꿈(` \n`) 처리
|
||||
- ✅ 연속 줄바꿈 정리
|
||||
- ✅ 카카오톡 SimpleText 호환
|
||||
|
||||
---
|
||||
|
||||
## 📝 테스트 방법
|
||||
|
||||
### **1. 실제 카카오톡에서 테스트**
|
||||
```
|
||||
사용자: "타이어펑크나면"
|
||||
예상 결과:
|
||||
1. "답변을 생성하고 있습니다..." 즉시 표시
|
||||
2. 몇 초 후 실제 답변으로 업데이트
|
||||
```
|
||||
|
||||
### **2. 로그 확인**
|
||||
```
|
||||
✅ callbackUrl이 출력되는지 확인:
|
||||
callbackUrl: https://kakaoi-chatbot-prod.kakao.com/callback/xxxxx
|
||||
|
||||
✅ Callback 전송 성공 확인:
|
||||
Callback 전송 완료. 상태: 200 OK
|
||||
```
|
||||
|
||||
### **3. 관리자 모드 테스트**
|
||||
```
|
||||
- callbackUrl 없음 확인
|
||||
- 동기 처리로 진행 확인
|
||||
- 5초 이내 답변만 정상 처리
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
1. **callbackUrl은 실제 환경에서만 제공**
|
||||
- 관리자 모드 테스트: callbackUrl 없음 (동기 처리)
|
||||
- 실제 사용자: callbackUrl 제공 (Callback 처리)
|
||||
|
||||
2. **callbackUrl 유효 기간**
|
||||
- 보통 1분 정도
|
||||
- 시간 내에 Callback 전송 필요
|
||||
|
||||
3. **Callback 응답 형식**
|
||||
- `useCallback` 설정하지 않음
|
||||
- 일반 SkillResponse와 동일한 형식
|
||||
|
||||
4. **비동기 처리 예외**
|
||||
- 오류 발생 시에도 반드시 Callback 전송
|
||||
- 사용자에게 적절한 에러 메시지 제공
|
||||
|
||||
---
|
||||
|
||||
## 📚 참고 문서
|
||||
|
||||
- **카카오 i 오픈빌더 공식 문서**: https://kakaobusiness.gitbook.io/main/tool/chatbot/skill_guide/make_skill
|
||||
- **스킬 응답 시간 제한**: 5초
|
||||
- **Callback 사용법**: useCallback 옵션 및 비동기 응답 처리
|
||||
|
||||
---
|
||||
|
||||
## 🎯 결론
|
||||
|
||||
**5초 타임아웃 문제를 Callback 방식으로 완벽히 해결했습니다!**
|
||||
|
||||
✅ RAG API 응답 시간에 관계없이 안정적으로 동작
|
||||
✅ 사용자 경험 개선 (처리중 메시지 → 최종 답변)
|
||||
✅ callbackUrl 유무에 따라 자동으로 최적 처리 방식 선택
|
||||
✅ 코드 구조 개선 및 유지보수성 향상
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-01-06
|
||||
**구현 완료**: ✅ 빌드 성공, 테스트 준비 완료
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# kakaoChatbotSkill — 카카오/웹 진입(Web) 서비스
|
||||
|
||||
한국도로공사 챗봇의 **진입 계층(Web)** 입니다. 카카오 오픈빌더 웹훅과 웹앱(chatbotApp)의 요청을 받아 **Agent(RAG `/agent/chat`)** 로 위임하고, 응답을 카카오/웹 UI로 조립합니다.
|
||||
|
||||
> **운영:** `128.200.100.61:8083` (DMZ 웹 서버, nginx 뒤)
|
||||
> **운영 모드:** `agent.mode=agent` — WAS·RAG는 모드 분기 없이 Agent tool API와 `/agent/chat`를 제공합니다.
|
||||
> **전체 구조:** [`../SYSTEM_ARCHITECTURE.md`](../SYSTEM_ARCHITECTURE.md) · **Agent 상세:** [`../docs/AGENT_ARCHITECTURE.md`](../docs/AGENT_ARCHITECTURE.md)
|
||||
> **콜백 구현:** [`CALLBACK_구현_정리.md`](CALLBACK_구현_정리.md)
|
||||
> **최종 갱신:** 2026-07-10
|
||||
|
||||
---
|
||||
|
||||
## 1. 역할
|
||||
|
||||
| 책임 | 설명 |
|
||||
|------|------|
|
||||
| 카카오 웹훅 수신 | `/kakao/skill` (폴백 블록 발화) |
|
||||
| 웹앱 수신 | `/web/ask`(JSON), `/web/ask/stream`(SSE) — chatbotApp |
|
||||
| 오케스트레이션 위임 | RAG `/agent/chat` 호출 |
|
||||
| WEB domain tool 제공 | `/internal/tools/data-portal` (WAS 역호출 전용, `GASSTATION`·`BRAND_SHOP`·`REST_AREA_FOOD_LIST`) |
|
||||
| UI 조립 | Agent 응답 → SimpleText·ListCard·BasicCard·QuickReply·fareBreakdown |
|
||||
|
||||
**핵심 원칙:** 정책(`fetchOwner`/`uiType`/`needsClarification`)은 WAS+YAML이 결정하고, Skill은 **진입·위임·UI 표현**만 담당합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 요청 흐름
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
K[카카오 오픈빌더] -->|/kakao/skill| SC[SkillController]
|
||||
APP[chatbotApp/브라우저] -->|/web/ask, /web/ask/stream| WC[WebChatController]
|
||||
|
||||
SC --> RB[ResultBuilder → KakaoIntentApp]
|
||||
WC --> WS[WebChatService] --> RB
|
||||
|
||||
RB --> RAG[RagAgentClient → RAG :28012 /agent/chat]
|
||||
|
||||
ITC[InternalToolController /internal/tools/data-portal] --> IDP[InternalDataPortalToolService → data.ex.co.kr]
|
||||
WAS2[WAS tool execute, agent WEB tool] -->|역호출| ITC
|
||||
|
||||
RAG --> UI[buildLlmAgentOutputs + SkillResponseWebExtractor]
|
||||
UI --> OUT([카카오 SimpleText/카드 · WebAskResponse])
|
||||
```
|
||||
|
||||
- **Agent 모드:** Skill → RAG `/agent/chat` 1회. WEB domain 데이터는 WAS가 `/internal/tools/data-portal`로 **역호출**해 가져옵니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 진입 API
|
||||
|
||||
| Method | URL | 구현 | 용도 |
|
||||
|--------|-----|------|------|
|
||||
| `POST`/`GET` | `/kakao/skill` (`/oakak/skill` 별칭) | `SkillController` | 카카오 폴백 블록 |
|
||||
| `POST` | `/web/ask` | `WebChatController` → `WebChatService` | 웹 JSON 응답 (`WebAskResponse`) |
|
||||
| `POST` | `/web/ask/stream` | `WebChatController` | SSE 스트리밍 (chatbotApp) |
|
||||
| `POST` | `/internal/tools/data-portal` | `InternalToolController` | WAS→Skill WEB tool (내부 전용, `X-Internal-Tool-Key`) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 주요 구현 파일
|
||||
|
||||
| 파일 | 책임 |
|
||||
|------|------|
|
||||
| `controller/SkillController.java` | 카카오 웹훅 진입 |
|
||||
| `response/build/ResultBuilder.java` | intent → 핸들러 라우팅 |
|
||||
| `response/build/KakaoIntentApp.java` | 카카오 fallback 블록, Agent 호출, 카드 UI 조립, `buildLlmAgentOutputs` |
|
||||
| `service/RagAgentClient.java` | RAG `POST /agent/chat` 클라이언트 |
|
||||
| `web/WebChatController.java`·`WebChatService.java` | `/web/ask`(+stream) → Agent 호출 및 웹 응답 매핑 |
|
||||
| `web/SkillResponseWebExtractor.java` | SkillResponse → 웹 DTO(ListCard·quickReplies·fareBreakdown) |
|
||||
| `api/InternalToolController.java` | WAS→Skill internal API |
|
||||
| `service/InternalDataPortalToolService.java`·`DataPortalService.java` | WEB tool data.ex.co.kr 조회 |
|
||||
| `config/AgentProperties.java` | `agent.mode`, `fallback-legacy`, `agent.chat.url` |
|
||||
| `config/InternalToolProperties.java` | `internal.tool.api-key` |
|
||||
|
||||
---
|
||||
|
||||
## 5. 설정 (jar 옆 `application.yml`)
|
||||
|
||||
```yaml
|
||||
agent:
|
||||
mode: agent
|
||||
fallback-legacy: true # 장애 대응용 fallback 설정
|
||||
chat:
|
||||
url: http://172.16.180.130:28012/agent/chat
|
||||
timeout-ms: 100000
|
||||
internal:
|
||||
tool:
|
||||
api-key: ${INTERNAL_TOOL_API_KEY} # WAS↔Skill 내부 인증(3서비스 동일)
|
||||
```
|
||||
|
||||
**방화벽:** `172.16.180.130 → 128.200.100.61:8083` (WAS→Skill WEB tool), `128.200.100.61 → 172.16.180.130:28012`(Skill→RAG)·`:8086`(Skill→WAS).
|
||||
|
||||
---
|
||||
|
||||
## 6. botId · 세션 정책
|
||||
|
||||
| 채널 | botId | 이력·pending |
|
||||
|------|-------|--------------|
|
||||
| 카카오 | `userRequest.user.id` (사용자별 고정) | 24h 내 동일 사용자 이력·pending 이어짐 |
|
||||
| 웹(chatbotApp) | `sessionStorage` 랜덤(`web-...`) — **대화마다 새 botId(무상태)** | 새 대화는 이력·pending 없음 |
|
||||
|
||||
- 카카오↔웹은 세션을 **연계하지 않고**, 웹은 대화마다 새 botId로 시작해 **과거 대화를 복원하지 않습니다**. 상세: [`../SYSTEM_ARCHITECTURE.md`](../SYSTEM_ARCHITECTURE.md).
|
||||
- pending(되묻기)은 Agent 모드에서 **RAG(MongoDB `agent_pending`)** 가 소유하며 Skill은 무상태입니다. Skill은 `query + botId`만 RAG로 전달합니다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 관련 문서
|
||||
|
||||
| 문서 | 내용 |
|
||||
|------|------|
|
||||
| [`../SYSTEM_ARCHITECTURE.md`](../SYSTEM_ARCHITECTURE.md) | 전체 구조·API·포트·세션 정책 |
|
||||
| [`../docs/AGENT_ARCHITECTURE.md`](../docs/AGENT_ARCHITECTURE.md) | Agent 모드 상세·방화벽 |
|
||||
| [`CALLBACK_구현_정리.md`](CALLBACK_구현_정리.md) | 카카오 콜백(5초 초과 응답) 구현 |
|
||||
| [`../chatbotApp/README.md`](../chatbotApp/README.md) | 웹앱 UI·`/web/ask` 연동 |
|
||||
@@ -0,0 +1,43 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'org.springframework.boot' version '2.7.10'
|
||||
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
|
||||
}
|
||||
|
||||
group = 'com.ex.chatbot'
|
||||
version = '0.0.1-SNAPSHOT'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
}
|
||||
sourceCompatibility = '1.8'
|
||||
targetCompatibility = '1.8'
|
||||
}
|
||||
|
||||
configurations {
|
||||
compileOnly {
|
||||
extendsFrom annotationProcessor
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.apache.httpcomponents:httpclient:4.5.13'
|
||||
implementation 'org.reflections:reflections:0.9.12'
|
||||
implementation 'com.google.code.gson:gson:2.8.9'
|
||||
implementation 'org.jsoup:jsoup:1.14.2'
|
||||
implementation 'commons-io:commons-io:2.8.0'
|
||||
|
||||
compileOnly 'org.projectlombok:lombok'
|
||||
annotationProcessor 'org.projectlombok:lombok'
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# kakaoChatbotSkill — 웹 서버 기동 시 export (Spring Boot는 .env 자동 로드 안 함)
|
||||
# 사용: set -a && source env.example && set +a && ./start.sh start
|
||||
|
||||
# WAS 서버
|
||||
CHATBOT_API_BASE_URL=http://172.16.180.130:8086/api/
|
||||
|
||||
# RAG Agent (WAS 서버와 동일 호스트)
|
||||
RAG_AGENT_CHAT_URL=http://172.16.180.130:28012/agent/chat
|
||||
AGENT_CHAT_TIMEOUT_MS=100000
|
||||
|
||||
# Agent 전환 시: legacy | agent
|
||||
AGENT_MODE=legacy
|
||||
|
||||
# WAS/RAG ↔ Skill internal tool 공통 키 (운영에서 반드시 설정)
|
||||
# INTERNAL_TOOL_API_KEY=
|
||||
@@ -0,0 +1 @@
|
||||
org.gradle.java.home=C:\\Program Files\\Eclipse Adoptium\\jdk-17.0.16.8-hotspot
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line,
|
||||
# like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=${0##*/}
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# shell script including quotes and variable substitutions, so put them in
|
||||
# double quotes to make sure that they get re-expanded; and
|
||||
# * put everything else in single quotes, so that it's not re-expanded.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,344 @@
|
||||
|
||||
#user nobody;
|
||||
worker_processes auto;
|
||||
|
||||
#error_log logs/error.log;
|
||||
#error_log logs/error.log notice;
|
||||
#error_log logs/error.log info;
|
||||
|
||||
#pid logs/nginx.pid;
|
||||
|
||||
|
||||
events {
|
||||
worker_connections 2048;
|
||||
}
|
||||
|
||||
|
||||
http {
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
server_tokens off;
|
||||
|
||||
#log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
# '$status $body_bytes_sent "$http_referer" '
|
||||
# '"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
#access_log logs/access.log main;
|
||||
|
||||
sendfile on;
|
||||
#tcp_nopush on;
|
||||
|
||||
#keepalive_timeout 0;
|
||||
keepalive_timeout 65;
|
||||
|
||||
gzip on;
|
||||
gzip_static on;
|
||||
gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
gzip_proxied any;
|
||||
gzip_vary on;
|
||||
gzip_comp_level 6;
|
||||
gzip_buffers 16 8k;
|
||||
gzip_http_version 1.1;
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name chatbot.ex.co.kr;
|
||||
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
|
||||
server_name chatbot.ex.co.kr;
|
||||
|
||||
ssl_certificate /home/song/exWebChat/chained.ex.co.kr.pem;
|
||||
ssl_certificate_key /home/song/exWebChat/prv_ast.ex.co.kr.key;
|
||||
ssl_dhparam /home/song/letsencrypt/dhparam.pem;
|
||||
ssl_protocols TLSv1.2;
|
||||
|
||||
location / {
|
||||
#root html;
|
||||
#index index.html;
|
||||
add_header 'Access-Control-Allow-Origin' 'https://www.extoll.co.kr';
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# chatbotApp 정적 UI
|
||||
# URL: https://chatbot.ex.co.kr/chatbot/app/
|
||||
# 파일 위치: /home/song/chatbotApp/public/
|
||||
# =========================================================================
|
||||
|
||||
location = /chatbot/app {
|
||||
return 301 /chatbot/app/;
|
||||
}
|
||||
|
||||
location = /chatbot/app/ {
|
||||
alias /home/song/chatbotApp/public/;
|
||||
index index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
location = /chatbot/app/index.html {
|
||||
alias /home/song/chatbotApp/public/index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
location ^~ /chatbot/app/_next/ {
|
||||
alias /home/song/chatbotApp/public/_next/;
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
location ^~ /chatbot/app/ {
|
||||
alias /home/song/chatbotApp/public/;
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# chatbotApp AI UI
|
||||
# URL: https://chatbot.ex.co.kr/chatbot/ai/
|
||||
# 파일 위치: /home/song/chatbotApp/public-ai/
|
||||
# =========================================================================
|
||||
|
||||
location = /chatbot/ai {
|
||||
return 301 /chatbot/ai/;
|
||||
}
|
||||
|
||||
location = /chatbot/ai/ {
|
||||
alias /home/song/chatbotApp/public-ai/;
|
||||
index index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
location = /chatbot/ai/index.html {
|
||||
alias /home/song/chatbotApp/public-ai/index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
location ^~ /chatbot/ai/_next/ {
|
||||
alias /home/song/chatbotApp/public-ai/_next/;
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
location ^~ /chatbot/ai/ {
|
||||
alias /home/song/chatbotApp/public-ai/;
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# chatbotApp → Skill 웹 채팅 API
|
||||
# chatbotApp 기본 API root: /chatbot
|
||||
# /chatbot/ask → Skill /web/ask
|
||||
# /chatbot/ask/stream → Skill /web/ask/stream
|
||||
# /chatbot/openapi.json → Skill /web/openapi.json
|
||||
# =========================================================================
|
||||
|
||||
location = /chatbot/ask {
|
||||
proxy_pass http://localhost:8083/web/ask;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location = /chatbot/ask/stream {
|
||||
proxy_pass http://localhost:8083/web/ask/stream;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
|
||||
# SSE 설정
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
gzip off;
|
||||
add_header X-Accel-Buffering no;
|
||||
}
|
||||
|
||||
location = /chatbot/openapi.json {
|
||||
proxy_pass http://localhost:8083/web/openapi.json;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# chatbotApp FAQ / Session / Health → RAG API
|
||||
# FAQ 버튼, 세션 추적, 헬스체크용
|
||||
# RAG: 172.16.180.130:28012
|
||||
# =========================================================================
|
||||
|
||||
location ^~ /chatbot/faq/ {
|
||||
proxy_pass http://172.16.180.130:28012/chatbot/faq/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location ^~ /chatbot/session/ {
|
||||
proxy_pass http://172.16.180.130:28012/chatbot/session/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location = /chatbot/health {
|
||||
proxy_pass http://172.16.180.130:28012/chatbot/health;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
|
||||
|
||||
location /kakao {
|
||||
#root html;
|
||||
#index index.html;
|
||||
proxy_pass http://localhost:8081;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
}
|
||||
|
||||
location /oakak {
|
||||
#root html;
|
||||
#index index.html;
|
||||
proxy_pass http://localhost:8082;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
}
|
||||
|
||||
location /extoll {
|
||||
#root html;
|
||||
#index index.html;
|
||||
proxy_pass http://localhost:8084;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
}
|
||||
|
||||
location /test/ {
|
||||
#root html;
|
||||
#index index.html;
|
||||
proxy_pass http://localhost:8083/oakak/;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
}
|
||||
|
||||
|
||||
location /images {
|
||||
alias /usr/local/nginx/data/images;
|
||||
}
|
||||
|
||||
location /fonts {
|
||||
alias /usr/local/nginx/data/fonts;
|
||||
}
|
||||
|
||||
location /card {
|
||||
alias /usr/local/nginx/data/card;
|
||||
}
|
||||
|
||||
#location /.well-known {
|
||||
# root html/well-known;
|
||||
#}
|
||||
|
||||
# redirect server error pages to the static page /50x.html
|
||||
#
|
||||
error_page 404 500 502 503 504 /error.html;
|
||||
location /error.html {
|
||||
root /usr/local/nginx/html/errors/;
|
||||
internal;
|
||||
}
|
||||
|
||||
}
|
||||
# another virtual host using mix of IP-, name-, and port-based configuration
|
||||
#
|
||||
#server {
|
||||
# listen 8000;
|
||||
# listen somename:8080;
|
||||
# server_name somename alias another.alias;
|
||||
|
||||
# location / {
|
||||
# root html;
|
||||
# index index.html index.htm;
|
||||
# }
|
||||
#}
|
||||
|
||||
|
||||
# HTTPS server
|
||||
#
|
||||
#server {
|
||||
# listen 443 ssl;
|
||||
# server_name localhost;
|
||||
|
||||
# ssl_certificate cert.pem;
|
||||
# ssl_certificate_key cert.key;
|
||||
|
||||
# ssl_session_cache shared:SSL:1m;
|
||||
# ssl_session_timeout 5m;
|
||||
|
||||
# ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
# ssl_prefer_server_ciphers on;
|
||||
|
||||
# location / {
|
||||
# root html;
|
||||
# index index.html index.htm;
|
||||
# }
|
||||
#}
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = 'kakao'
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.ex.chatbot.kakao;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class KakaoApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(KakaoApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.ex.chatbot.kakao.api;
|
||||
|
||||
import com.ex.chatbot.kakao.api.dto.DataPortalToolRequest;
|
||||
import com.ex.chatbot.kakao.config.InternalToolProperties;
|
||||
import com.ex.chatbot.kakao.service.InternalDataPortalToolService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* chatbotApi Agent tool execution용 내부 API.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/internal/tools")
|
||||
@RequiredArgsConstructor
|
||||
public class InternalToolController {
|
||||
|
||||
private final InternalDataPortalToolService internalDataPortalToolService;
|
||||
private final InternalToolProperties internalToolProperties;
|
||||
|
||||
@PostMapping("/data-portal")
|
||||
public ResponseEntity<Map<String, Object>> fetchDataPortal(
|
||||
@RequestHeader(value = "X-Internal-Tool-Key", required = false) String apiKey,
|
||||
@RequestBody DataPortalToolRequest request
|
||||
) {
|
||||
ResponseEntity<Map<String, Object>> authError = authorize(apiKey);
|
||||
if (authError != null) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
String intentType = request != null ? request.getIntentType() : null;
|
||||
Map<String, Object> params = request != null && request.getParams() != null
|
||||
? request.getParams()
|
||||
: new HashMap<>();
|
||||
String userInput = request != null ? request.getUserInput() : null;
|
||||
|
||||
log.info("[InternalTool] data-portal - intentType: {}, params: {}", intentType, params);
|
||||
Map<String, Object> result = internalDataPortalToolService.fetch(intentType, params, userInput);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> authorize(String apiKey) {
|
||||
String expected = internalToolProperties.getApiKey();
|
||||
if (expected == null || expected.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
if (expected.equals(apiKey)) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("status", false);
|
||||
body.put("statusMsg", "invalid or missing X-Internal-Tool-Key");
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(body);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.ex.chatbot.kakao.api.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
public class DataPortalToolRequest {
|
||||
private String intentType;
|
||||
private Map<String, Object> params = new HashMap<>();
|
||||
private String userInput;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.ex.chatbot.kakao.config;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "agent")
|
||||
public class AgentProperties {
|
||||
|
||||
/**
|
||||
* legacy | agent — RAG /agent/chat vs WAS /llm-agent
|
||||
*/
|
||||
private String mode = "legacy";
|
||||
|
||||
private boolean fallbackLegacy = true;
|
||||
|
||||
private Chat chat = new Chat();
|
||||
|
||||
public boolean isAgentMode() {
|
||||
return "agent".equalsIgnoreCase(mode);
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Chat {
|
||||
private String url = "http://172.16.180.130:28012/agent/chat";
|
||||
private int timeoutMs = 100_000;
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.ex.chatbot.kakao.config;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "internal.tool")
|
||||
public class InternalToolProperties {
|
||||
private String apiKey = "";
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.ex.chatbot.kakao.config;
|
||||
|
||||
import org.apache.http.client.HttpClient;
|
||||
import org.apache.http.impl.client.HttpClientBuilder;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
@Configuration
|
||||
public class RestfulConfig {
|
||||
@Value("${restTemplate.factory.readTimeout}")
|
||||
private int READ_TIMEOUT;
|
||||
|
||||
@Value("${restTemplate.factory.connectTimeout}")
|
||||
private int CONNECT_TIMEOUT;
|
||||
|
||||
@Value("${restTemplate.httpClient.maxConnTotal}")
|
||||
private int MAX_CONN_TOTAL;
|
||||
|
||||
@Value("${restTemplate.httpClient.maxConnPerRoute}")
|
||||
private int MAX_CONN_PER_ROUTE;
|
||||
|
||||
@Value("${agent.chat.timeout-ms:100000}")
|
||||
private int AGENT_READ_TIMEOUT;
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public RestTemplate restTemplate() {
|
||||
return buildRestTemplate(READ_TIMEOUT);
|
||||
}
|
||||
|
||||
@Bean(name = "agentRestTemplate")
|
||||
public RestTemplate agentRestTemplate() {
|
||||
return buildRestTemplate(AGENT_READ_TIMEOUT);
|
||||
}
|
||||
|
||||
private RestTemplate buildRestTemplate(int readTimeout) {
|
||||
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
|
||||
factory.setReadTimeout(readTimeout);
|
||||
factory.setConnectTimeout(CONNECT_TIMEOUT);
|
||||
|
||||
HttpClient httpClient = HttpClientBuilder.create()
|
||||
.setMaxConnTotal(MAX_CONN_TOTAL)
|
||||
.setMaxConnPerRoute(MAX_CONN_PER_ROUTE)
|
||||
.build();
|
||||
|
||||
factory.setHttpClient(httpClient);
|
||||
return new RestTemplate(factory);
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.ex.chatbot.kakao.controller;
|
||||
|
||||
import com.ex.chatbot.kakao.skillRequest.KakaoRequest;
|
||||
import com.ex.chatbot.kakao.response.build.ResultBuilder;
|
||||
import com.ex.chatbot.kakao.response.element.SkillResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
@RestController
|
||||
public class SkillController {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(SkillController.class);
|
||||
|
||||
@Autowired
|
||||
ResultBuilder resultBuilder;
|
||||
|
||||
@RequestMapping(value = { "/oakak/skill", "/kakao/skill" }, method = { RequestMethod.POST,
|
||||
RequestMethod.GET }, headers = { "Accept=application/json" })
|
||||
public @ResponseBody SkillResponse skillRequest(@RequestBody KakaoRequest req, HttpServletRequest request) {
|
||||
SkillResponse res = null;
|
||||
try {
|
||||
// ===== 카카오 요청 IP 및 헤더 정보 로깅 =====
|
||||
String xForwardedFor = request.getHeader("X-Forwarded-For");
|
||||
String xRealIp = request.getHeader("X-Real-IP");
|
||||
String remoteAddr = request.getRemoteAddr();
|
||||
|
||||
// 실제 클라이언트 IP 추출
|
||||
String clientIp = null;
|
||||
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
|
||||
// X-Forwarded-For의 첫 번째 IP가 실제 클라이언트 IP
|
||||
int idx = xForwardedFor.indexOf(',');
|
||||
clientIp = (idx > 0) ? xForwardedFor.substring(0, idx).trim() : xForwardedFor.trim();
|
||||
} else if (xRealIp != null && !xRealIp.isEmpty()) {
|
||||
clientIp = xRealIp;
|
||||
} else {
|
||||
clientIp = remoteAddr;
|
||||
}
|
||||
|
||||
LOGGER.info("========================================");
|
||||
LOGGER.info("=== Kakao Bot Request Info ===");
|
||||
LOGGER.info("Client IP (Detected): {}", clientIp);
|
||||
LOGGER.info("X-Forwarded-For: {}", xForwardedFor);
|
||||
LOGGER.info("X-Real-IP: {}", xRealIp);
|
||||
LOGGER.info("Remote Address: {}", remoteAddr);
|
||||
LOGGER.info("Request URI: {}", request.getRequestURI());
|
||||
LOGGER.info("Request Method: {}", request.getMethod());
|
||||
LOGGER.info("User-Agent: {}", request.getHeader("User-Agent"));
|
||||
LOGGER.info("X-Request-ID: {}", request.getHeader("X-Request-ID"));
|
||||
LOGGER.info("KakaoI-Instance: {}", request.getHeader("KakaoI-Instance"));
|
||||
LOGGER.info("========================================");
|
||||
// ============================================
|
||||
|
||||
String intent = req.getIntent().getName();
|
||||
LOGGER.info("Intent: {}", intent);
|
||||
res = (SkillResponse) resultBuilder.getResult(intent, req);
|
||||
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("SkillResponse error occured : {}", e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.ex.chatbot.kakao.response.build;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface KakaoIntent {
|
||||
String[] value();
|
||||
}
|
||||
+2939
File diff suppressed because it is too large
Load Diff
+61
@@ -0,0 +1,61 @@
|
||||
package com.ex.chatbot.kakao.response.build;
|
||||
|
||||
import com.ex.chatbot.kakao.skillRequest.KakaoRequest;
|
||||
import org.reflections.Reflections;
|
||||
import org.reflections.scanners.MethodAnnotationsScanner;
|
||||
import org.reflections.util.ClasspathHelper;
|
||||
import org.reflections.util.ConfigurationBuilder;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import javax.annotation.PostConstruct;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class ResultBuilder {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ResultBuilder.class);
|
||||
private Map<String, Method> kakaoMethod = new HashMap<>();
|
||||
@Autowired
|
||||
KakaoIntentApp kakaoIntentApp;
|
||||
|
||||
@PostConstruct
|
||||
public void getMeThodIntent() /*getIntent */ {
|
||||
Reflections reflections = new Reflections(new ConfigurationBuilder()
|
||||
.setUrls(ClasspathHelper.forPackage("com.ex.chatbot.kakao")).setScanners(new MethodAnnotationsScanner()));
|
||||
|
||||
Set<Method> methods = reflections.getMethodsAnnotatedWith(KakaoIntent.class);
|
||||
|
||||
try {
|
||||
methods.forEach(method -> {
|
||||
KakaoIntent commandMappingAnnotation = method.getAnnotation(KakaoIntent.class);
|
||||
String[] keys = commandMappingAnnotation.value();
|
||||
for(String key : keys){
|
||||
kakaoMethod.put(key, method);
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
LOGGER.info("{}", kakaoMethod);
|
||||
}
|
||||
|
||||
public Object getResult(String intent, KakaoRequest req) {
|
||||
Method method = kakaoMethod.get(intent);
|
||||
LOGGER.info(intent);
|
||||
LOGGER.info(String.valueOf(method));
|
||||
Object result = null;
|
||||
try {
|
||||
result = method.invoke(kakaoIntentApp, req);
|
||||
} catch (Exception e) {
|
||||
result = kakaoIntentApp.
|
||||
getError("method 실행 에러 {}", e.getMessage());
|
||||
LOGGER.error(String.valueOf(e));
|
||||
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.ex.chatbot.kakao.response.build;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class ValidCheck {
|
||||
|
||||
public static boolean isCarNo(String carNo) {
|
||||
String pattern = "\\d{2,3}[가-힣]\\d{4}$";
|
||||
String pattern2 = "^[서울|부산|대구|인천|대전|광주|울산|제주|경기|강원|충남|전남|전북|경남|경북|세종]{2}\\d{2,3}[가-힣]\\d{4}$";
|
||||
|
||||
if (Pattern.matches(pattern, carNo) || Pattern.matches(pattern2, carNo)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package com.ex.chatbot.kakao.response.element;
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.template.Template;
|
||||
import com.ex.chatbot.kakao.response.element.type.ContextControl;
|
||||
import com.ex.chatbot.kakao.response.element.type.ContextValue;
|
||||
import com.ex.chatbot.kakao.response.element.type.QuickReply;
|
||||
import com.ex.chatbot.kakao.response.element.type.SkillTemplate;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Component
|
||||
public class SkillResponse {
|
||||
public final static String KAKAO_VERSION = "2.0";
|
||||
@JsonProperty("version")
|
||||
private String version;
|
||||
@JsonProperty("useCallback")
|
||||
private boolean useCallback;
|
||||
@JsonProperty("template")
|
||||
private SkillTemplate template;
|
||||
@JsonProperty("context")
|
||||
private ContextControl context;
|
||||
@JsonProperty("data")
|
||||
private Map<String, Object> data;
|
||||
|
||||
public SkillResponse() {
|
||||
this.version = KAKAO_VERSION;
|
||||
this.template = new SkillTemplate();
|
||||
this.useCallback = false; // 기본값 false
|
||||
}
|
||||
|
||||
public SkillResponse(String version, boolean useCallback, SkillTemplate template, ContextControl context, Map<String, Object> data) {
|
||||
this.version = version;
|
||||
this.useCallback = useCallback;
|
||||
this.template = template;
|
||||
this.context = context;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
// SkillTemplate을 인자로 받는 생성자
|
||||
public SkillResponse(SkillTemplate template) {
|
||||
this.version = KAKAO_VERSION;
|
||||
this.template = template;
|
||||
this.useCallback = false; // 기본값 false
|
||||
}
|
||||
|
||||
// useCallback을 인자로 받는 생성자
|
||||
public SkillResponse(boolean useCallback) {
|
||||
this.version = KAKAO_VERSION;
|
||||
this.template = new SkillTemplate();
|
||||
this.useCallback = useCallback;
|
||||
}
|
||||
|
||||
public SkillResponse getResult(Template output, List<QuickReply> quickReplies) {
|
||||
this.template = SkillTemplate.builder()
|
||||
.addOutput(output)
|
||||
.quickReplies(quickReplies)
|
||||
.build();
|
||||
return this;
|
||||
}
|
||||
|
||||
public SkillResponse getResult(List<Template> outputs, List<QuickReply> quickReplies) {
|
||||
this.template = SkillTemplate.builder()
|
||||
.outputs(outputs)
|
||||
.quickReplies(quickReplies)
|
||||
.build();
|
||||
return this;
|
||||
}
|
||||
|
||||
public SkillResponse getResult(List<Template> outputs, List<QuickReply> quickReplies, List<ContextValue> values) {
|
||||
this.template = SkillTemplate.builder()
|
||||
.outputs(outputs)
|
||||
.quickReplies(quickReplies)
|
||||
.build();
|
||||
this.context = ContextControl.builder()
|
||||
.values(values)
|
||||
.build();
|
||||
return this;
|
||||
}
|
||||
|
||||
// data 필드를 설정하는 메서드 추가
|
||||
public SkillResponse setData(Map<String, Object> data) {
|
||||
this.data = data;
|
||||
return this;
|
||||
}
|
||||
|
||||
// 수동으로 빌더 패턴 구현
|
||||
public static SkillResponseBuilder builder() {
|
||||
return new SkillResponseBuilder();
|
||||
}
|
||||
|
||||
public static class SkillResponseBuilder {
|
||||
private String version = KAKAO_VERSION;
|
||||
private boolean useCallback;
|
||||
private SkillTemplate template;
|
||||
private ContextControl context;
|
||||
private Map<String, Object> data;
|
||||
|
||||
public SkillResponseBuilder version(String version) {
|
||||
this.version = version;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SkillResponseBuilder useCallback(boolean useCallback) {
|
||||
this.useCallback = useCallback;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SkillResponseBuilder template(SkillTemplate template) {
|
||||
this.template = template;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SkillResponseBuilder context(ContextControl context) {
|
||||
this.context = context;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SkillResponseBuilder data(Map<String, Object> data) {
|
||||
this.data = data;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SkillResponse build() {
|
||||
return new SkillResponse(version, useCallback, template, context, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.ex.chatbot.kakao.response.element.View;
|
||||
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.template.BasicCard;
|
||||
import com.ex.chatbot.kakao.response.element.template.SimpleText;
|
||||
import com.ex.chatbot.kakao.response.element.template.Template;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
public class BasicCardView implements Template, CanCarousel {
|
||||
|
||||
private BasicCard basicCard;
|
||||
|
||||
static public Template getSimpleText(String textbody) {
|
||||
SimpleTextView stv =
|
||||
SimpleTextView.builder()
|
||||
.simpleText(
|
||||
SimpleText.builder()
|
||||
.text(textbody)
|
||||
.build())
|
||||
.build();
|
||||
return stv;
|
||||
}
|
||||
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package com.ex.chatbot.kakao.response.element.View;
|
||||
|
||||
public interface CanCarousel {
|
||||
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.ex.chatbot.kakao.response.element.View;
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.template.Carousel;
|
||||
import com.ex.chatbot.kakao.response.element.template.Template;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
public class CarouselView implements Template {
|
||||
|
||||
private Carousel carousel;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.ex.chatbot.kakao.response.element.View;
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.template.CommerceCard;
|
||||
import com.ex.chatbot.kakao.response.element.template.Template;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
public class CommerceCardView implements Template, CanCarousel {
|
||||
|
||||
private CommerceCard commerceCard;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.ex.chatbot.kakao.response.element.View;
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.template.ListCard;
|
||||
import com.ex.chatbot.kakao.response.element.template.Template;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
public class ListCardView implements Template {
|
||||
|
||||
private ListCard listCard;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.ex.chatbot.kakao.response.element.View;
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.template.SimpleImage;
|
||||
import com.ex.chatbot.kakao.response.element.template.Template;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
public class SimpleImageView implements Template {
|
||||
|
||||
private SimpleImage simpleImage;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.ex.chatbot.kakao.response.element.View;
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.template.SimpleText;
|
||||
import com.ex.chatbot.kakao.response.element.template.Template;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class SimpleTextView implements Template {
|
||||
|
||||
private SimpleText simpleText;
|
||||
|
||||
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.ex.chatbot.kakao.response.element.template;
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.View.BasicCardView;
|
||||
import com.ex.chatbot.kakao.response.element.type.buttons.Button;
|
||||
import com.ex.chatbot.kakao.response.element.type.subtype.Profile;
|
||||
import com.ex.chatbot.kakao.response.element.type.subtype.Social;
|
||||
import com.ex.chatbot.kakao.response.element.type.subtype.Thumbnail;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class BasicCard implements Template {
|
||||
private String title;
|
||||
private String description;
|
||||
private Thumbnail thumbnail;
|
||||
private Profile profile;
|
||||
private Social social;
|
||||
private List<Button> buttons;
|
||||
|
||||
static public Template getBasicCard(String title, String description, String url, Profile profile, ArrayList<Button> buttons) {
|
||||
BasicCardView bcv =
|
||||
BasicCardView.builder()
|
||||
.basicCard(
|
||||
BasicCard.builder()
|
||||
.title(title)
|
||||
.description(description)
|
||||
.thumbnail(Thumbnail.builder()
|
||||
.imageUrl(url)
|
||||
.build())
|
||||
.buttons(buttons)
|
||||
.build())
|
||||
.build();
|
||||
return bcv;
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.ex.chatbot.kakao.response.element.template;
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.View.CarouselView;
|
||||
import com.ex.chatbot.kakao.response.element.type.subtype.CarouselHeader;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.Singular;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class Carousel implements Template {
|
||||
private String type;
|
||||
private CarouselHeader header;
|
||||
@Singular("addItem")
|
||||
private List<Template> items; //BasicCard or CommercCard 만 가능
|
||||
|
||||
static public Template getCarousel(String type, List<Template> items) {
|
||||
CarouselView cv = CarouselView.builder()
|
||||
.carousel(Carousel.builder()
|
||||
.type(type)
|
||||
.items(items).build())
|
||||
.build();
|
||||
return cv;
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.ex.chatbot.kakao.response.element.template;
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.View.CommerceCardView;
|
||||
import com.ex.chatbot.kakao.response.element.type.buttons.Button;
|
||||
import com.ex.chatbot.kakao.response.element.type.subtype.Profile;
|
||||
import com.ex.chatbot.kakao.response.element.type.subtype.Thumbnail;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class CommerceCard implements Template {
|
||||
|
||||
private String description;
|
||||
private Comparable price;
|
||||
private Comparable currency;
|
||||
private Comparable discount;
|
||||
private Comparable discountRate;
|
||||
private Comparable discountedPrice;
|
||||
private ArrayList<Thumbnail> thumbnails;
|
||||
private Profile profile;
|
||||
private List<Button> buttons;
|
||||
|
||||
static public Template getCommerceCard(String description, Comparable price, String currency, ArrayList<Thumbnail> thumbnails, Profile profile, ArrayList<Button> button) {
|
||||
CommerceCardView ccv = CommerceCardView.builder()
|
||||
.commerceCard(CommerceCard.builder()
|
||||
.description(description)
|
||||
.price(price)
|
||||
.currency(currency)
|
||||
.thumbnails(thumbnails)
|
||||
.profile(profile)
|
||||
.buttons(button)
|
||||
.build())
|
||||
.build();
|
||||
return ccv;
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.ex.chatbot.kakao.response.element.template;
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.View.ListCardView;
|
||||
import com.ex.chatbot.kakao.response.element.type.Item;
|
||||
import com.ex.chatbot.kakao.response.element.type.buttons.Button;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class ListCard implements Template {
|
||||
|
||||
private Item header;
|
||||
//private ListItem items;
|
||||
private List<Item> items;
|
||||
private List<Button> buttons;
|
||||
|
||||
static public Template getListCard(String title, String url, List<Item> items, ArrayList<Button> buttons) {
|
||||
ListCardView lcv = ListCardView.builder()
|
||||
.listCard(ListCard.builder()
|
||||
.header(Item.builder()
|
||||
.title(title)
|
||||
.imageUrl(url)
|
||||
.build())
|
||||
.items(items)
|
||||
.buttons(buttons)
|
||||
.build())
|
||||
.build();
|
||||
return lcv;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.ex.chatbot.kakao.response.element.template;
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.View.SimpleImageView;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class SimpleImage implements Template {
|
||||
|
||||
private String imageUrl;
|
||||
private String altText;
|
||||
|
||||
static public Template getSimpleImage(String url, String altTxt) {
|
||||
SimpleImageView siv =
|
||||
SimpleImageView.builder()
|
||||
.simpleImage(
|
||||
SimpleImage.builder()
|
||||
.imageUrl(url)
|
||||
.altText(altTxt)
|
||||
.build())
|
||||
.build();
|
||||
return siv;
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.ex.chatbot.kakao.response.element.template;
|
||||
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.View.SimpleTextView;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class SimpleText implements Template {
|
||||
private String text;
|
||||
|
||||
static public Template getSimpleText(String textbody) {
|
||||
SimpleTextView stv =
|
||||
SimpleTextView.builder()
|
||||
.simpleText(
|
||||
SimpleText.builder()
|
||||
.text(textbody)
|
||||
.build())
|
||||
.build();
|
||||
return stv;
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package com.ex.chatbot.kakao.response.element.template;
|
||||
|
||||
public interface Template {
|
||||
|
||||
}
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.ex.chatbot.kakao.response.element.type;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class Block {
|
||||
String id;
|
||||
String name;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.ex.chatbot.kakao.response.element.type;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class ContextControl {
|
||||
List<ContextValue> values;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.ex.chatbot.kakao.response.element.type;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class ContextValue {
|
||||
String name;
|
||||
int lifeSpan;
|
||||
Map<String, String> params;
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.ex.chatbot.kakao.response.element.type;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class DetailParam {
|
||||
// Not Yet.
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.ex.chatbot.kakao.response.element.type;
|
||||
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.type.subtype.Link;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class Item {
|
||||
|
||||
private String title;
|
||||
private String description;
|
||||
private String imageUrl;
|
||||
private Link link;
|
||||
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.ex.chatbot.kakao.response.element.type;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class QuickReply {
|
||||
|
||||
private String label;
|
||||
private String action;
|
||||
private String messageText;
|
||||
private String blockId;
|
||||
private Object extra;
|
||||
|
||||
public static QuickReply getMessageReply(String label, String messageText) {
|
||||
QuickReply quickReply = QuickReply.builder()
|
||||
.label(label)
|
||||
.action("message")
|
||||
.messageText(messageText)
|
||||
.build();
|
||||
return quickReply;
|
||||
}
|
||||
|
||||
public static QuickReply getBlockReply(String label, String blockId) {
|
||||
QuickReply quickReply = QuickReply.builder()
|
||||
.label(label)
|
||||
.action("block")
|
||||
.messageText(null)
|
||||
.blockId(blockId)
|
||||
.build();
|
||||
return quickReply;
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package com.ex.chatbot.kakao.response.element.type;
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.template.Template;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.Singular;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class SkillTemplate {
|
||||
|
||||
final static private Character MAX_OUTPUTS_SIZE = 3;
|
||||
final static private Character MIN_OUTPUTS_SIZE = 1;
|
||||
final static private Character MAX_QUICKREPLY_SIZE = 10;
|
||||
|
||||
@Singular("addOutput")
|
||||
private List<Template> outputs;
|
||||
private List<QuickReply> quickReplies;
|
||||
|
||||
public SkillTemplate(){
|
||||
this.outputs = new ArrayList<>();
|
||||
this.quickReplies = new ArrayList<>();
|
||||
}
|
||||
|
||||
public SkillTemplate(List<Template> ou, List<QuickReply> qr){
|
||||
this.outputs = ou;
|
||||
this.quickReplies = qr;
|
||||
}
|
||||
|
||||
public void addOutput(Template ou){
|
||||
if(this.outputs.size() + 1 > MAX_OUTPUTS_SIZE){
|
||||
throw new IndexOutOfBoundsException("outputs size must be at least 1 and no more than 3.");
|
||||
}
|
||||
this.outputs.add(ou);
|
||||
}
|
||||
|
||||
public void initOutputs(){
|
||||
this.outputs = new ArrayList<Template>();
|
||||
}
|
||||
|
||||
public void setOutputs(ArrayList<Template> outputs){
|
||||
if(outputs.size() > MAX_OUTPUTS_SIZE){
|
||||
throw new IndexOutOfBoundsException("outputs size must be at least 1 and no more than 3.");
|
||||
}
|
||||
this.outputs = outputs;
|
||||
}
|
||||
|
||||
public void setOutput(Template outputs){
|
||||
this.outputs = new ArrayList<Template>();
|
||||
this.outputs.add(outputs);
|
||||
}
|
||||
|
||||
public void addQuickReply(QuickReply qr){
|
||||
if(this.quickReplies.size() + 1 > MAX_QUICKREPLY_SIZE){
|
||||
throw new IndexOutOfBoundsException("quickReplies size must be no more than 10.");
|
||||
}
|
||||
this.quickReplies.add(qr);
|
||||
}
|
||||
|
||||
public void initQuickReplies(){
|
||||
this.quickReplies = new ArrayList<QuickReply>();
|
||||
}
|
||||
|
||||
public void setQuickReplies(List<QuickReply> quickReplies){
|
||||
if ( quickReplies.size() > MAX_QUICKREPLY_SIZE ){
|
||||
throw new IndexOutOfBoundsException("quickReplies size must be no more than 10.");
|
||||
}
|
||||
this.quickReplies = quickReplies;
|
||||
}
|
||||
|
||||
public void setQuickReplies(QuickReply qreply){
|
||||
this.quickReplies = new ArrayList<>();
|
||||
this.quickReplies.add(qreply);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.ex.chatbot.kakao.response.element.type;
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.type.subtype.Properties;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
public class User {
|
||||
|
||||
public Properties properties;
|
||||
|
||||
private String id;
|
||||
private String type;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.ex.chatbot.kakao.response.element.type.buttons;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class BlockButton implements Button {
|
||||
|
||||
private String label;
|
||||
@Builder.Default
|
||||
private String action = "block";
|
||||
private String messageText;
|
||||
private String blockId;
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.ex.chatbot.kakao.response.element.type.buttons;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public interface Button {
|
||||
|
||||
Map<String, Object> extra = null;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.ex.chatbot.kakao.response.element.type.buttons;
|
||||
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class MessageButton implements Button {
|
||||
|
||||
String label;
|
||||
@Builder.Default
|
||||
private String action = "message";
|
||||
private String messageText;
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.ex.chatbot.kakao.response.element.type.buttons;
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.type.subtype.Link;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class OsLinkButton implements Button {
|
||||
private String label;
|
||||
@Builder.Default
|
||||
private String action = "osLink";
|
||||
private Link osLink;
|
||||
|
||||
public static OsLinkButton getOsLinkButton(String label, String pc, String android, String ios) {
|
||||
OsLinkButton button = OsLinkButton.builder()
|
||||
.label(label)
|
||||
.osLink(Link.builder()
|
||||
.pc(pc)
|
||||
.android(android)
|
||||
.ios(ios)
|
||||
.build())
|
||||
.build();
|
||||
return button;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.ex.chatbot.kakao.response.element.type.buttons;
|
||||
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@Builder
|
||||
@Data
|
||||
public class PhoneButton implements Button {
|
||||
|
||||
private String label;
|
||||
@Builder.Default
|
||||
private String action = "phone";
|
||||
private String phoneNumber;
|
||||
|
||||
public static PhoneButton getPhoneButton(String label, String phoneNumber ) {
|
||||
PhoneButton button = PhoneButton.builder()
|
||||
.label(label)
|
||||
.phoneNumber(phoneNumber)
|
||||
.build();
|
||||
return button;
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.ex.chatbot.kakao.response.element.type.buttons;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@Builder
|
||||
@Data
|
||||
public class ShareButton implements Button {
|
||||
|
||||
private String label;
|
||||
@Builder.Default
|
||||
private String action = "share";
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.ex.chatbot.kakao.response.element.type.buttons;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class WebLinkButton implements Button {
|
||||
|
||||
private String label;
|
||||
@Builder.Default
|
||||
private String action = "webLink";
|
||||
private String webLinkUrl;
|
||||
|
||||
public static WebLinkButton gWebLinkButton(String label, String webLinkUrl){
|
||||
WebLinkButton button = WebLinkButton.builder()
|
||||
.label(label)
|
||||
.webLinkUrl(webLinkUrl)
|
||||
.build();
|
||||
return button;
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.ex.chatbot.kakao.response.element.type.subtype;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class CarouselHeader {
|
||||
|
||||
private String title;
|
||||
private String description;
|
||||
private Thumbnail thumbnail;
|
||||
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.ex.chatbot.kakao.response.element.type.subtype;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
public class Link {
|
||||
private String mobile;
|
||||
private String ios;
|
||||
private String android;
|
||||
private String pc;
|
||||
private String mac;
|
||||
private String win;
|
||||
private String web;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.ex.chatbot.kakao.response.element.type.subtype;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class Profile {
|
||||
private String nickname;
|
||||
private String imageUrl;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.ex.chatbot.kakao.response.element.type.subtype;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
public class Properties {
|
||||
|
||||
private String plusfriendUserKey;
|
||||
private String appUserId;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.ex.chatbot.kakao.response.element.type.subtype;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
public class Social {
|
||||
|
||||
private Long like;
|
||||
private Long comment;
|
||||
private Long share;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.ex.chatbot.kakao.response.element.type.subtype;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
public class Thumbnail {
|
||||
private String imageUrl;
|
||||
private Link link;
|
||||
private Boolean fixedRaio;
|
||||
private Integer width;
|
||||
private Integer height;
|
||||
|
||||
}
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
package com.ex.chatbot.kakao.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 한국도로공사 데이터포털 (data.ex.co.kr) 외부 API 호출 서비스
|
||||
* Web(skill) 계층에서 직접 호출 — WAS(chatbotApi)에서는 네트워크 접근 불가
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class DataPortalService {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
@Value("${data-portal.base-url}")
|
||||
private String baseUrl;
|
||||
|
||||
@Value("${data-portal.api-key}")
|
||||
private String apiKey;
|
||||
|
||||
@Value("${data-portal.max-rows:100}")
|
||||
private String maxRows;
|
||||
|
||||
public DataPortalService(RestTemplate restTemplate) {
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 주유소 현황
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> getGasstationInfo(String restAreaName, String restAreaDirection) {
|
||||
log.info("{} 휴게소 주유 정보 조회 시작 (방향: {})", restAreaName, restAreaDirection);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
try {
|
||||
String url = baseUrl + "/business/curStateStation?key=" + apiKey + "&type=json"
|
||||
+ "&numOfRows=" + maxRows + "&pageNo=1"
|
||||
+ "&serviceAreaName=" + restAreaName;
|
||||
|
||||
Map<String, Object> apiResult = restTemplate.getForObject(url, Map.class);
|
||||
if (apiResult == null) return errorResponse("데이터포털 응답이 없습니다.");
|
||||
|
||||
int count = toInt(apiResult.get("count"));
|
||||
log.info("[data.ex.co.kr][주유] {} 휴게소 원본 count={}", restAreaName, count);
|
||||
if (count == 0)
|
||||
return errorResponse(restAreaName + " 휴게소 주유(충전소) 정보가 없습니다.");
|
||||
|
||||
List<Map<String, Object>> list = (List<Map<String, Object>>) apiResult.get("list");
|
||||
if (list == null || list.isEmpty())
|
||||
return errorResponse(restAreaName + " 휴게소 주유(충전소) 정보가 없습니다.");
|
||||
log.info("[data.ex.co.kr][주유] {} 휴게소 원본 listSize={}", restAreaName, list.size());
|
||||
|
||||
list = list.stream()
|
||||
.filter(g -> {
|
||||
String nm = (String) g.get("serviceAreaName");
|
||||
return nm != null && nm.startsWith(restAreaName);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
log.info("[data.ex.co.kr][주유] {} 휴게소 이름 필터 후 listSize={}", restAreaName, list.size());
|
||||
|
||||
if (list.isEmpty()) return errorResponse(restAreaName + " 휴게소 주유(충전소) 정보가 없습니다.");
|
||||
|
||||
boolean bidirectional = list.size() >= 2 && restAreaDirection == null;
|
||||
if (bidirectional) {
|
||||
List<String> directions = list.stream()
|
||||
.map(g -> (String) g.get("direction"))
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
response.put("status", true);
|
||||
response.put("statusMsg", "정상응답");
|
||||
response.put("restAreaName", restAreaName);
|
||||
response.put("bidirectional", true);
|
||||
response.put("directions", directions);
|
||||
response.put("list", list);
|
||||
log.info("[data.ex.co.kr][주유] {} 휴게소 조회 성공 - bidirectional=true, directions={}, listSize={}",
|
||||
restAreaName, directions, list.size());
|
||||
return response;
|
||||
}
|
||||
|
||||
String anotherDirection = null;
|
||||
if (restAreaDirection != null) {
|
||||
String dirFilter = restAreaDirection.replace("방향", "");
|
||||
for (Map<String, Object> g : list) {
|
||||
String dir = (String) g.get("direction");
|
||||
if (dir != null && !dir.equals(dirFilter)) anotherDirection = dir;
|
||||
}
|
||||
final String finalDirFilter = dirFilter;
|
||||
list = list.stream()
|
||||
.filter(g -> finalDirFilter.equals(g.get("direction")))
|
||||
.collect(Collectors.toList());
|
||||
log.info("[data.ex.co.kr][주유] {} 휴게소 방향 필터({}) 후 listSize={}",
|
||||
restAreaName, finalDirFilter, list.size());
|
||||
}
|
||||
|
||||
if (list.isEmpty())
|
||||
return errorResponse(restAreaName + " 휴게소 " + restAreaDirection + " 방향 주유정보가 없습니다.");
|
||||
|
||||
response.put("status", true);
|
||||
response.put("statusMsg", "정상응답");
|
||||
response.put("restAreaName", restAreaName);
|
||||
response.put("bidirectional", false);
|
||||
response.put("anotherDirection", anotherDirection);
|
||||
response.put("list", list);
|
||||
log.info("[data.ex.co.kr][주유] {} 휴게소 조회 성공 - direction={}, anotherDirection={}, listSize={}",
|
||||
restAreaName, restAreaDirection, anotherDirection, list.size());
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("주유소 정보 조회 오류: {}", e.getMessage(), e);
|
||||
return errorResponse("서비스에 일시적인 장애가 발생하였습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 브랜드 매장 목록
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> getBrandShopList(String restAreaName, String brand, Integer startIndex) {
|
||||
log.info("{} 휴게소 브랜드 매장 정보 조회 시작 (brand: {}, startIndex: {})", restAreaName, brand, startIndex);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
try {
|
||||
String url = baseUrl + "/restinfo/restBrandList?key=" + apiKey + "&type=json"
|
||||
+ "&numOfRows=" + maxRows + "&pageNo=1"
|
||||
+ "&stdRestNm=" + restAreaName;
|
||||
|
||||
Map<String, Object> apiResult = restTemplate.getForObject(url, Map.class);
|
||||
if (apiResult == null) return errorResponse("데이터포털 응답이 없습니다.");
|
||||
|
||||
List<Map<String, Object>> list = (List<Map<String, Object>>) apiResult.get("list");
|
||||
if (list == null || list.isEmpty())
|
||||
return errorResponse(restAreaName + " 휴게소 매장 정보가 없습니다.");
|
||||
log.info("[data.ex.co.kr][브랜드] {} 휴게소 원본 listSize={}, brand={}",
|
||||
restAreaName, list.size(), brand);
|
||||
|
||||
String regex = "(^" + restAreaName + "\\([가-힣]*\\)휴게소$)|(^" + restAreaName + "휴게소$)";
|
||||
Pattern p = Pattern.compile(regex);
|
||||
list = list.stream()
|
||||
.filter(b -> {
|
||||
String nm = (String) b.get("stdRestNm");
|
||||
return nm != null && p.matcher(nm).find();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
log.info("[data.ex.co.kr][브랜드] {} 휴게소 이름 필터 후 listSize={}", restAreaName, list.size());
|
||||
|
||||
if (brand != null) {
|
||||
final String brandFilter = brand;
|
||||
list = list.stream()
|
||||
.filter(b -> brandFilter.equals(b.get("brdName")))
|
||||
.collect(Collectors.toList());
|
||||
log.info("[data.ex.co.kr][브랜드] {} 휴게소 브랜드 필터({}) 후 listSize={}",
|
||||
restAreaName, brandFilter, list.size());
|
||||
if (list.isEmpty())
|
||||
return errorResponse(restAreaName + " 휴게소에는 " + brand + " 매장이 없습니다.");
|
||||
}
|
||||
|
||||
if (list.isEmpty()) return errorResponse(restAreaName + " 휴게소 매장 정보가 없습니다.");
|
||||
|
||||
int totalCount = list.size();
|
||||
if (startIndex != null && startIndex > 0 && startIndex < totalCount) {
|
||||
list = list.subList(startIndex, totalCount);
|
||||
}
|
||||
|
||||
response.put("status", true);
|
||||
response.put("statusMsg", "정상응답");
|
||||
response.put("restAreaName", restAreaName);
|
||||
response.put("brand", brand);
|
||||
response.put("totalCount", totalCount);
|
||||
response.put("startIndex", startIndex != null ? startIndex : 0);
|
||||
response.put("list", list);
|
||||
log.info("[data.ex.co.kr][브랜드] {} 휴게소 조회 성공 - brand={}, totalCount={}, returnedSize={}",
|
||||
restAreaName, brand, totalCount, list.size());
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("브랜드 매장 정보 조회 오류: {}", e.getMessage(), e);
|
||||
return errorResponse("서비스에 일시적인 장애가 발생하였습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 음식 목록
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> getFoodList(String restAreaName) {
|
||||
log.info("{} 휴게소 음식 목록 조회 시작", restAreaName);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
try {
|
||||
String url = baseUrl + "/restinfo/restBestfoodList?key=" + apiKey + "&type=json"
|
||||
+ "&numOfRows=" + maxRows + "&pageNo=1"
|
||||
+ "&stdRestNm=" + restAreaName;
|
||||
|
||||
Map<String, Object> apiResult = restTemplate.getForObject(url, Map.class);
|
||||
if (apiResult == null) return errorResponse("데이터포털 응답이 없습니다.");
|
||||
|
||||
int count = toInt(apiResult.get("count"));
|
||||
log.info("[data.ex.co.kr][음식] {} 휴게소 원본 count={}", restAreaName, count);
|
||||
if (count == 0)
|
||||
return errorResponse(restAreaName + " 휴게소 식사 메뉴 정보가 없습니다.");
|
||||
|
||||
List<Map<String, Object>> list = (List<Map<String, Object>>) apiResult.get("list");
|
||||
if (list == null || list.isEmpty())
|
||||
return errorResponse(restAreaName + " 휴게소 식사 메뉴 정보가 없습니다.");
|
||||
log.info("[data.ex.co.kr][음식] {} 휴게소 원본 listSize={}", restAreaName, list.size());
|
||||
|
||||
String regex = "(^" + restAreaName + "\\([가-힣]*\\)휴게소$)|(^" + restAreaName + "휴게소$)";
|
||||
Pattern p = Pattern.compile(regex);
|
||||
list = list.stream()
|
||||
.filter(f -> {
|
||||
String nm = (String) f.get("stdRestNm");
|
||||
return nm != null && p.matcher(nm).find();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
log.info("[data.ex.co.kr][음식] {} 휴게소 이름 필터 후 listSize={}", restAreaName, list.size());
|
||||
|
||||
if (list.isEmpty()) return errorResponse(restAreaName + " 휴게소 식사 메뉴 정보가 없습니다.");
|
||||
|
||||
Map<String, StringBuilder> msgMap = new HashMap<>();
|
||||
for (Map<String, Object> foodInfo : list) {
|
||||
String stdRestNm = valueAsString(foodInfo.get("stdRestNm"));
|
||||
msgMap.computeIfAbsent(stdRestNm, k -> new StringBuilder());
|
||||
String foodNm = valueAsString(foodInfo.get("foodNm"));
|
||||
String foodCost = valueAsString(foodInfo.get("foodCost"));
|
||||
String price = "0".equals(foodCost) ? "(선택 메뉴에 따라 가격 상이)" : foodCost + "원";
|
||||
msgMap.get(stdRestNm).append("- ").append(foodNm).append(" ").append(price).append("\n");
|
||||
}
|
||||
|
||||
Map<String, String> msgMapStr = new HashMap<>();
|
||||
msgMap.forEach((k, v) -> msgMapStr.put(k, v.toString().trim()));
|
||||
|
||||
response.put("status", true);
|
||||
response.put("statusMsg", "정상응답");
|
||||
response.put("restAreaName", restAreaName);
|
||||
response.put("list", list);
|
||||
response.put("msgMap", msgMapStr);
|
||||
log.info("[data.ex.co.kr][음식] {} 휴게소 조회 성공 - listSize={}, msgMapKeys={}",
|
||||
restAreaName, list.size(), msgMapStr.keySet());
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("음식 목록 조회 오류: {}", e.getMessage(), e);
|
||||
return errorResponse("서비스에 일시적인 장애가 발생하였습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> errorResponse(String msg) {
|
||||
log.warn("[data.ex.co.kr] 조회 실패 - {}", msg);
|
||||
Map<String, Object> r = new HashMap<>();
|
||||
r.put("status", false);
|
||||
r.put("statusMsg", msg);
|
||||
return r;
|
||||
}
|
||||
|
||||
private int toInt(Object value) {
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).intValue();
|
||||
}
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(String.valueOf(value));
|
||||
} catch (NumberFormatException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private String valueAsString(Object value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return String.valueOf(value).trim();
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package com.ex.chatbot.kakao.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* WAS Agent tool이 호출하는 WEB domain fetch (data.ex.co.kr).
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InternalDataPortalToolService {
|
||||
|
||||
private final DataPortalService dataPortalService;
|
||||
|
||||
public Map<String, Object> fetch(String intentType, Map<String, Object> params, String userInput) {
|
||||
Map<String, Object> safeParams = params != null ? new HashMap<>(params) : new HashMap<>();
|
||||
Map<String, Object> domainData = fetchByIntent(intentType, safeParams);
|
||||
if ("GASSTATION".equals(intentType)) {
|
||||
domainData = refineGasstationDirectionFromUserInput(intentType, safeParams, userInput, domainData);
|
||||
}
|
||||
return domainData;
|
||||
}
|
||||
|
||||
private Map<String, Object> fetchByIntent(String intentType, Map<String, Object> params) {
|
||||
String restAreaName = stringValue(params.get("restAreaName"));
|
||||
if ("GASSTATION".equals(intentType)) {
|
||||
return dataPortalService.getGasstationInfo(
|
||||
restAreaName,
|
||||
stringValue(params.get("restAreaDirection"))
|
||||
);
|
||||
}
|
||||
if ("BRAND_SHOP".equals(intentType)) {
|
||||
return dataPortalService.getBrandShopList(
|
||||
restAreaName,
|
||||
stringValue(params.get("brand")),
|
||||
integerValue(params.get("startIndex"))
|
||||
);
|
||||
}
|
||||
if ("REST_AREA_FOOD_LIST".equals(intentType)) {
|
||||
return dataPortalService.getFoodList(restAreaName);
|
||||
}
|
||||
|
||||
Map<String, Object> unsupported = new HashMap<>();
|
||||
unsupported.put("status", false);
|
||||
unsupported.put("statusMsg", "지원하지 않는 외부 데이터 조회 유형입니다: " + intentType);
|
||||
return unsupported;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> refineGasstationDirectionFromUserInput(
|
||||
String intentType,
|
||||
Map<String, Object> params,
|
||||
String userInput,
|
||||
Map<String, Object> domainData
|
||||
) {
|
||||
if (domainData == null || userInput == null || userInput.isBlank()) {
|
||||
return domainData;
|
||||
}
|
||||
if (params.get("restAreaDirection") != null) {
|
||||
return domainData;
|
||||
}
|
||||
if (!Boolean.TRUE.equals(domainData.get("bidirectional"))) {
|
||||
return domainData;
|
||||
}
|
||||
|
||||
Object rawDirections = domainData.get("directions");
|
||||
if (!(rawDirections instanceof List)) {
|
||||
return domainData;
|
||||
}
|
||||
|
||||
String normalizedInput = normalizeDirectionText(userInput);
|
||||
for (Object directionObj : (List<Object>) rawDirections) {
|
||||
String direction = stringValue(directionObj);
|
||||
if (direction == null) {
|
||||
continue;
|
||||
}
|
||||
if (normalizedInput.contains(normalizeDirectionText(direction))) {
|
||||
params.put("restAreaDirection", direction);
|
||||
log.info("[InternalTool] 주유소 방향 보정 - direction: {}", direction);
|
||||
return fetchByIntent(intentType, params);
|
||||
}
|
||||
}
|
||||
return domainData;
|
||||
}
|
||||
|
||||
private String normalizeDirectionText(Object value) {
|
||||
String text = stringValue(value);
|
||||
if (text == null) {
|
||||
return "";
|
||||
}
|
||||
return text.replaceAll("\\s+", "")
|
||||
.replace("방향", "")
|
||||
.replace("행", "");
|
||||
}
|
||||
|
||||
private String stringValue(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
private Integer integerValue(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).intValue();
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(String.valueOf(value).trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.ex.chatbot.kakao.service;
|
||||
|
||||
import com.ex.chatbot.kakao.config.AgentProperties;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class RagAgentClient {
|
||||
|
||||
private final RestTemplate agentRestTemplate;
|
||||
private final AgentProperties agentProperties;
|
||||
|
||||
public RagAgentClient(
|
||||
@Qualifier("agentRestTemplate") RestTemplate agentRestTemplate,
|
||||
AgentProperties agentProperties
|
||||
) {
|
||||
this.agentRestTemplate = agentRestTemplate;
|
||||
this.agentProperties = agentProperties;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> chat(String query, String botId) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("query", query);
|
||||
body.put("botId", botId != null ? botId : "anonymous");
|
||||
|
||||
long started = System.currentTimeMillis();
|
||||
Map<String, Object> response = agentRestTemplate.postForObject(
|
||||
agentProperties.getChat().getUrl(),
|
||||
new HttpEntity<>(body, headers),
|
||||
Map.class
|
||||
);
|
||||
long elapsed = System.currentTimeMillis() - started;
|
||||
|
||||
log.info(
|
||||
"[Agent] POST /agent/chat elapsed_ms={} routeType={} intentType={}",
|
||||
elapsed,
|
||||
response != null ? response.get("routeType") : null,
|
||||
response != null ? response.get("intentType") : null
|
||||
);
|
||||
|
||||
if (response == null) {
|
||||
Map<String, Object> fallback = new HashMap<>();
|
||||
fallback.put("answer", "죄송합니다. 답변을 생성하지 못했습니다.");
|
||||
return fallback;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.ex.chatbot.kakao.skillRequest;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
public class Action {
|
||||
String name;
|
||||
Map<String, Object> clientExtra;
|
||||
Map<String, String> params;
|
||||
String id;
|
||||
Map<String, Object> detailParams;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.ex.chatbot.kakao.skillRequest;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class Bot {
|
||||
private String id;
|
||||
private String name;
|
||||
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.ex.chatbot.kakao.skillRequest;
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.type.ContextControl;
|
||||
import com.ex.chatbot.kakao.response.element.type.SkillTemplate;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public class CallbackRequest {
|
||||
@JsonProperty("version")
|
||||
private String version;
|
||||
|
||||
@JsonProperty("template")
|
||||
private SkillTemplate template;
|
||||
|
||||
@JsonProperty("context")
|
||||
private ContextControl context;
|
||||
|
||||
@JsonProperty("useCallback")
|
||||
private boolean useCallback;
|
||||
|
||||
public CallbackRequest() {
|
||||
this.version = "2.0";
|
||||
this.useCallback = true;
|
||||
}
|
||||
|
||||
public CallbackRequest(SkillTemplate template, ContextControl context) {
|
||||
this.version = "2.0";
|
||||
this.template = template;
|
||||
this.context = context;
|
||||
this.useCallback = true;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public String getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public void setVersion(String version) {
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public SkillTemplate getTemplate() {
|
||||
return template;
|
||||
}
|
||||
|
||||
public void setTemplate(SkillTemplate template) {
|
||||
this.template = template;
|
||||
}
|
||||
|
||||
public ContextControl getContext() {
|
||||
return context;
|
||||
}
|
||||
|
||||
public void setContext(ContextControl context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public boolean isUseCallback() {
|
||||
return useCallback;
|
||||
}
|
||||
|
||||
public void setUseCallback(boolean useCallback) {
|
||||
this.useCallback = useCallback;
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.ex.chatbot.kakao.skillRequest;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class Intent {
|
||||
private String id;
|
||||
private String name;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.ex.chatbot.kakao.skillRequest;
|
||||
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class KakaoRequest {
|
||||
private Intent intent;
|
||||
private UserRequest userRequest;
|
||||
private Bot bot;
|
||||
private Action action;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.ex.chatbot.kakao.skillRequest;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
public class UserRequest {
|
||||
private String timezone;
|
||||
private Map<String, Object> params;
|
||||
private Map<String, Object> block;
|
||||
private String Utterance;
|
||||
private String lang;
|
||||
private Map<String, Object> user;
|
||||
private String callbackUrl;
|
||||
}
|
||||
+413
@@ -0,0 +1,413 @@
|
||||
package com.ex.chatbot.kakao.web;
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.SkillResponse;
|
||||
import com.ex.chatbot.kakao.response.element.View.BasicCardView;
|
||||
import com.ex.chatbot.kakao.response.element.View.ListCardView;
|
||||
import com.ex.chatbot.kakao.response.element.View.SimpleTextView;
|
||||
import com.ex.chatbot.kakao.response.element.template.BasicCard;
|
||||
import com.ex.chatbot.kakao.response.element.template.ListCard;
|
||||
import com.ex.chatbot.kakao.response.element.template.SimpleText;
|
||||
import com.ex.chatbot.kakao.response.element.template.Template;
|
||||
import com.ex.chatbot.kakao.response.element.type.Item;
|
||||
import com.ex.chatbot.kakao.response.element.type.QuickReply;
|
||||
import com.ex.chatbot.kakao.response.element.type.buttons.BlockButton;
|
||||
import com.ex.chatbot.kakao.response.element.type.buttons.Button;
|
||||
import com.ex.chatbot.kakao.response.element.type.buttons.MessageButton;
|
||||
import com.ex.chatbot.kakao.response.element.type.buttons.OsLinkButton;
|
||||
import com.ex.chatbot.kakao.response.element.type.buttons.PhoneButton;
|
||||
import com.ex.chatbot.kakao.response.element.type.buttons.WebLinkButton;
|
||||
import com.ex.chatbot.kakao.response.element.type.subtype.Link;
|
||||
import com.ex.chatbot.kakao.web.dto.WebActionButtonDto;
|
||||
import com.ex.chatbot.kakao.web.dto.WebListCardDto;
|
||||
import com.ex.chatbot.kakao.web.dto.WebListCardItemDto;
|
||||
import com.ex.chatbot.kakao.web.dto.WebQuickReplyDto;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* SkillResponse에서 웹 채팅용 텍스트와 ListCard UI 데이터를 분리 추출한다.
|
||||
*/
|
||||
@Component
|
||||
public class SkillResponseWebExtractor {
|
||||
|
||||
public WebExtractResult extract(SkillResponse response) {
|
||||
if (response == null || response.getTemplate() == null) {
|
||||
return WebExtractResult.empty();
|
||||
}
|
||||
|
||||
List<Template> outputs = response.getTemplate().getOutputs();
|
||||
if (outputs == null || outputs.isEmpty()) {
|
||||
return WebExtractResult.empty();
|
||||
}
|
||||
|
||||
List<String> textParts = new ArrayList<>();
|
||||
List<WebListCardDto> listCards = new ArrayList<>();
|
||||
List<WebActionButtonDto> actionButtons = new ArrayList<>();
|
||||
Set<String> seenActionButtons = new LinkedHashSet<>();
|
||||
List<WebQuickReplyDto> quickReplies = new ArrayList<>(extractQuickReplies(response));
|
||||
Set<String> seenQuickReplies = new LinkedHashSet<>();
|
||||
for (WebQuickReplyDto quickReply : quickReplies) {
|
||||
if (quickReply != null && quickReply.getMessage() != null) {
|
||||
seenQuickReplies.add(quickReply.getMessage().trim());
|
||||
}
|
||||
}
|
||||
|
||||
for (Template output : outputs) {
|
||||
if (output instanceof SimpleTextView) {
|
||||
appendSimpleText((SimpleTextView) output, textParts);
|
||||
} else if (output instanceof BasicCardView) {
|
||||
appendBasicCardText((BasicCardView) output, textParts, actionButtons, seenActionButtons,
|
||||
quickReplies, seenQuickReplies);
|
||||
} else if (output instanceof ListCardView) {
|
||||
appendListCard((ListCardView) output, listCards, actionButtons, seenActionButtons,
|
||||
quickReplies, seenQuickReplies);
|
||||
}
|
||||
}
|
||||
|
||||
String answer = String.join("\n\n", textParts).trim();
|
||||
Map<String, Object> fareBreakdown = extractFareBreakdown(response);
|
||||
if (fareBreakdown != null && !listCards.isEmpty()) {
|
||||
for (int i = listCards.size() - 1; i >= 0; i--) {
|
||||
WebListCardDto card = listCards.get(i);
|
||||
if (card.getTitle() != null && card.getTitle().contains("통행요금")) {
|
||||
card.setFareBreakdown(fareBreakdown);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new WebExtractResult(answer, listCards, quickReplies, actionButtons);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> extractFareBreakdown(SkillResponse response) {
|
||||
if (response == null || response.getData() == null) {
|
||||
return null;
|
||||
}
|
||||
Object value = response.getData().get("fareBreakdown");
|
||||
if (value instanceof Map) {
|
||||
return (Map<String, Object>) value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<WebQuickReplyDto> extractQuickReplies(SkillResponse response) {
|
||||
if (response.getTemplate() == null || response.getTemplate().getQuickReplies() == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<WebQuickReplyDto> result = new ArrayList<>();
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
|
||||
for (QuickReply quickReply : response.getTemplate().getQuickReplies()) {
|
||||
if (quickReply == null) {
|
||||
continue;
|
||||
}
|
||||
if (quickReply.getAction() != null && !"message".equals(quickReply.getAction())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String message = quickReply.getMessageText();
|
||||
if (message == null || message.isBlank()) {
|
||||
message = quickReply.getLabel();
|
||||
}
|
||||
if (message == null || message.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String normalized = message.trim();
|
||||
if (!seen.add(normalized)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String label = quickReply.getLabel();
|
||||
if (label == null || label.isBlank()) {
|
||||
label = normalized;
|
||||
}
|
||||
|
||||
result.add(WebQuickReplyDto.builder()
|
||||
.label(label.trim())
|
||||
.message(normalized)
|
||||
.build());
|
||||
|
||||
if (result.size() >= 10) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void appendSimpleText(SimpleTextView view, List<String> parts) {
|
||||
SimpleText simpleText = view.getSimpleText();
|
||||
if (simpleText != null && simpleText.getText() != null && !simpleText.getText().isBlank()) {
|
||||
parts.add(simpleText.getText().trim());
|
||||
}
|
||||
}
|
||||
|
||||
private void appendBasicCardText(
|
||||
BasicCardView view,
|
||||
List<String> parts,
|
||||
List<WebActionButtonDto> actionButtons,
|
||||
Set<String> seenActionButtons,
|
||||
List<WebQuickReplyDto> quickReplies,
|
||||
Set<String> seenQuickReplies
|
||||
) {
|
||||
BasicCard basicCard = view.getBasicCard();
|
||||
if (basicCard == null) {
|
||||
return;
|
||||
}
|
||||
if (basicCard.getTitle() != null && !basicCard.getTitle().isBlank()) {
|
||||
parts.add(basicCard.getTitle().trim());
|
||||
}
|
||||
if (basicCard.getDescription() != null && !basicCard.getDescription().isBlank()) {
|
||||
parts.add(basicCard.getDescription().trim());
|
||||
}
|
||||
appendButtons(basicCard.getButtons(), actionButtons, seenActionButtons, quickReplies, seenQuickReplies);
|
||||
}
|
||||
|
||||
private void appendListCard(
|
||||
ListCardView view,
|
||||
List<WebListCardDto> listCards,
|
||||
List<WebActionButtonDto> actionButtons,
|
||||
Set<String> seenActionButtons,
|
||||
List<WebQuickReplyDto> quickReplies,
|
||||
Set<String> seenQuickReplies
|
||||
) {
|
||||
ListCard listCard = view.getListCard();
|
||||
if (listCard == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String title = null;
|
||||
String headerImageUrl = null;
|
||||
Item header = listCard.getHeader();
|
||||
if (header != null) {
|
||||
if (header.getTitle() != null && !header.getTitle().isBlank()) {
|
||||
title = header.getTitle().trim();
|
||||
}
|
||||
headerImageUrl = header.getImageUrl();
|
||||
}
|
||||
|
||||
List<WebListCardItemDto> items = new ArrayList<>();
|
||||
List<Item> rawItems = listCard.getItems();
|
||||
if (rawItems != null) {
|
||||
for (Item item : rawItems) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
String itemTitle = item.getTitle() != null ? item.getTitle().trim() : "";
|
||||
String itemDesc = item.getDescription() != null ? item.getDescription().trim() : "";
|
||||
if (itemTitle.isBlank() && itemDesc.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
items.add(WebListCardItemDto.builder()
|
||||
.title(itemTitle)
|
||||
.description(itemDesc)
|
||||
.imageUrl(item.getImageUrl())
|
||||
.linkUrl(primaryLinkUrl(item.getLink()))
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
if (title == null && items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
listCards.add(WebListCardDto.builder()
|
||||
.title(title)
|
||||
.headerImageUrl(headerImageUrl)
|
||||
.items(items)
|
||||
.build());
|
||||
|
||||
appendButtons(listCard.getButtons(), actionButtons, seenActionButtons, quickReplies, seenQuickReplies);
|
||||
}
|
||||
|
||||
private void appendButtons(
|
||||
List<Button> buttons,
|
||||
List<WebActionButtonDto> actionButtons,
|
||||
Set<String> seenActionButtons,
|
||||
List<WebQuickReplyDto> quickReplies,
|
||||
Set<String> seenQuickReplies
|
||||
) {
|
||||
if (buttons == null || buttons.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Button button : buttons) {
|
||||
if (button == null) {
|
||||
continue;
|
||||
}
|
||||
WebActionButtonDto actionButton = toWebActionButton(button);
|
||||
if (actionButton != null) {
|
||||
String key = (actionButton.getAction() == null ? "" : actionButton.getAction()) + "|"
|
||||
+ (actionButton.getLabel() == null ? "" : actionButton.getLabel()) + "|"
|
||||
+ (actionButton.getUrl() == null ? "" : actionButton.getUrl()) + "|"
|
||||
+ (actionButton.getPhoneNumber() == null ? "" : actionButton.getPhoneNumber());
|
||||
if (seenActionButtons.add(key)) {
|
||||
actionButtons.add(actionButton);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
appendMessageButtonAsQuickReply(button, quickReplies, seenQuickReplies);
|
||||
}
|
||||
}
|
||||
|
||||
private WebActionButtonDto toWebActionButton(Button button) {
|
||||
if (button instanceof WebLinkButton) {
|
||||
WebLinkButton webLink = (WebLinkButton) button;
|
||||
String url = trimToNull(webLink.getWebLinkUrl());
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
return WebActionButtonDto.builder()
|
||||
.label(defaultLabel(webLink.getLabel(), "바로가기"))
|
||||
.action("webLink")
|
||||
.url(url)
|
||||
.webUrl(url)
|
||||
.build();
|
||||
}
|
||||
if (button instanceof OsLinkButton) {
|
||||
OsLinkButton osLinkButton = (OsLinkButton) button;
|
||||
Link link = osLinkButton.getOsLink();
|
||||
if (link == null) {
|
||||
return null;
|
||||
}
|
||||
String pc = trimToNull(link.getPc());
|
||||
String web = trimToNull(link.getWeb());
|
||||
String mobile = trimToNull(link.getMobile());
|
||||
String android = trimToNull(link.getAndroid());
|
||||
String ios = trimToNull(link.getIos());
|
||||
String primary = firstNonBlank(pc, web, mobile, android, ios);
|
||||
if (primary == null) {
|
||||
return null;
|
||||
}
|
||||
return WebActionButtonDto.builder()
|
||||
.label(defaultLabel(osLinkButton.getLabel(), "바로가기"))
|
||||
.action("osLink")
|
||||
.url(primary)
|
||||
.pcUrl(pc)
|
||||
.webUrl(web)
|
||||
.mobileUrl(mobile)
|
||||
.androidUrl(android)
|
||||
.iosUrl(ios)
|
||||
.build();
|
||||
}
|
||||
if (button instanceof PhoneButton) {
|
||||
PhoneButton phone = (PhoneButton) button;
|
||||
String phoneNumber = trimToNull(phone.getPhoneNumber());
|
||||
if (phoneNumber == null) {
|
||||
return null;
|
||||
}
|
||||
return WebActionButtonDto.builder()
|
||||
.label(defaultLabel(phone.getLabel(), "전화하기"))
|
||||
.action("phone")
|
||||
.phoneNumber(phoneNumber)
|
||||
.url("tel:" + phoneNumber.replaceAll("[^0-9+]", ""))
|
||||
.build();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void appendMessageButtonAsQuickReply(
|
||||
Button button,
|
||||
List<WebQuickReplyDto> quickReplies,
|
||||
Set<String> seenQuickReplies
|
||||
) {
|
||||
String label = null;
|
||||
String message = null;
|
||||
if (button instanceof MessageButton) {
|
||||
MessageButton messageButton = (MessageButton) button;
|
||||
label = messageButton.getLabel();
|
||||
message = messageButton.getMessageText();
|
||||
} else if (button instanceof BlockButton) {
|
||||
BlockButton blockButton = (BlockButton) button;
|
||||
label = blockButton.getLabel();
|
||||
message = blockButton.getMessageText();
|
||||
}
|
||||
message = trimToNull(message);
|
||||
if (message == null) {
|
||||
message = trimToNull(label);
|
||||
}
|
||||
if (message == null || !seenQuickReplies.add(message)) {
|
||||
return;
|
||||
}
|
||||
quickReplies.add(WebQuickReplyDto.builder()
|
||||
.label(defaultLabel(label, message))
|
||||
.message(message)
|
||||
.build());
|
||||
}
|
||||
|
||||
private String defaultLabel(String label, String fallback) {
|
||||
String trimmed = trimToNull(label);
|
||||
return trimmed != null ? trimmed : fallback;
|
||||
}
|
||||
|
||||
private String firstNonBlank(String... values) {
|
||||
for (String value : values) {
|
||||
String trimmed = trimToNull(value);
|
||||
if (trimmed != null) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String primaryLinkUrl(Link link) {
|
||||
if (link == null) {
|
||||
return null;
|
||||
}
|
||||
return firstNonBlank(link.getPc(), link.getWeb(), link.getMobile(), link.getAndroid(), link.getIos());
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
public static final class WebExtractResult {
|
||||
private final String answer;
|
||||
private final List<WebListCardDto> listCards;
|
||||
private final List<WebQuickReplyDto> quickReplies;
|
||||
private final List<WebActionButtonDto> actionButtons;
|
||||
|
||||
public WebExtractResult(
|
||||
String answer,
|
||||
List<WebListCardDto> listCards,
|
||||
List<WebQuickReplyDto> quickReplies,
|
||||
List<WebActionButtonDto> actionButtons
|
||||
) {
|
||||
this.answer = answer != null ? answer : "";
|
||||
this.listCards = listCards != null ? listCards : Collections.emptyList();
|
||||
this.quickReplies = quickReplies != null ? quickReplies : Collections.emptyList();
|
||||
this.actionButtons = actionButtons != null ? actionButtons : Collections.emptyList();
|
||||
}
|
||||
|
||||
public static WebExtractResult empty() {
|
||||
return new WebExtractResult("", Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
|
||||
}
|
||||
|
||||
public String getAnswer() {
|
||||
return answer;
|
||||
}
|
||||
|
||||
public List<WebListCardDto> getListCards() {
|
||||
return listCards;
|
||||
}
|
||||
|
||||
public List<WebQuickReplyDto> getQuickReplies() {
|
||||
return quickReplies;
|
||||
}
|
||||
|
||||
public List<WebActionButtonDto> getActionButtons() {
|
||||
return actionButtons;
|
||||
}
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
package com.ex.chatbot.kakao.web;
|
||||
|
||||
import com.ex.chatbot.kakao.config.AgentProperties;
|
||||
import com.ex.chatbot.kakao.service.DataPortalService;
|
||||
import com.ex.chatbot.kakao.service.RagAgentClient;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WebChatAgentService {
|
||||
|
||||
private final AgentProperties agentProperties;
|
||||
private final RagAgentClient ragAgentClient;
|
||||
private final DataPortalService dataPortalService;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
@Value("${chatbot-api.base-url:http://172.16.180.130:8086/api/}")
|
||||
private String chatbotApiBaseUrl;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
chatbotApiBaseUrl = normalizeBaseUrl(chatbotApiBaseUrl);
|
||||
log.info("[Web Chat] chatbotApi base-url: {}", chatbotApiBaseUrl);
|
||||
}
|
||||
|
||||
public Map<String, Object> ask(String userInput, String botId) throws Exception {
|
||||
if (agentProperties.isAgentMode()) {
|
||||
try {
|
||||
return ragAgentClient.chat(userInput, botId);
|
||||
} catch (Exception e) {
|
||||
log.warn("[Web Chat][Agent] /agent/chat 실패 - fallbackLegacy={}", agentProperties.isFallbackLegacy(), e);
|
||||
if (agentProperties.isFallbackLegacy()) {
|
||||
return callLegacyLlmAgentApi(userInput, botId);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return callLegacyLlmAgentApi(userInput, botId);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> callLegacyLlmAgentApi(String userInput, String botId) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("question", userInput);
|
||||
body.put("botId", botId);
|
||||
|
||||
String llmAgentUrl = chatbotApiUrl("v1/llm-agent");
|
||||
Map<String, Object> response = restTemplate.postForObject(
|
||||
llmAgentUrl,
|
||||
new HttpEntity<>(body, headers),
|
||||
Map.class
|
||||
);
|
||||
|
||||
log.info("[Web Chat][Legacy] 응답 수신 - routeType: {}, intentType: {}, uiType: {}",
|
||||
response != null ? response.get("routeType") : null,
|
||||
response != null ? response.get("intentType") : null,
|
||||
response != null ? response.get("uiType") : null);
|
||||
|
||||
if (response == null) {
|
||||
Map<String, Object> fallback = new HashMap<>();
|
||||
fallback.put("answer", "죄송합니다. 답변을 생성하지 못했습니다.");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if ("web_fetch_required".equals(response.get("routeType"))) {
|
||||
return fetchWebDomainDataAndCallAgent(userInput, botId, response);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> fetchWebDomainDataAndCallAgent(
|
||||
String userInput,
|
||||
String botId,
|
||||
Map<String, Object> routeResponse
|
||||
) {
|
||||
String intentType = String.valueOf(routeResponse.get("intentType"));
|
||||
Map<String, Object> params = routeResponse.get("params") instanceof Map
|
||||
? (Map<String, Object>) routeResponse.get("params")
|
||||
: new HashMap<>();
|
||||
|
||||
log.info("[Web Chat][Domain Fetch] intentType: {}, params: {}", intentType, params);
|
||||
Map<String, Object> domainData = fetchWebDomainData(intentType, params);
|
||||
domainData = refineGasstationDirectionFromUserInput(intentType, params, userInput, domainData);
|
||||
if (domainData == null || domainData.isEmpty()) {
|
||||
Map<String, Object> fallback = new HashMap<>();
|
||||
fallback.put("answer", "죄송합니다. 조회할 수 있는 정보를 찾지 못했습니다.");
|
||||
fallback.put("intentType", intentType);
|
||||
fallback.put("params", params);
|
||||
fallback.put("domainData", domainData);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return callAgentApiWithDomainData(userInput, botId, intentType, params, domainData, routeResponse);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> refineGasstationDirectionFromUserInput(
|
||||
String intentType,
|
||||
Map<String, Object> params,
|
||||
String userInput,
|
||||
Map<String, Object> domainData
|
||||
) {
|
||||
if (!"GASSTATION".equals(intentType)
|
||||
|| params.get("restAreaDirection") != null
|
||||
|| domainData == null
|
||||
|| !Boolean.TRUE.equals(domainData.get("bidirectional"))) {
|
||||
return domainData;
|
||||
}
|
||||
|
||||
Object rawDirections = domainData.get("directions");
|
||||
if (!(rawDirections instanceof List)) {
|
||||
return domainData;
|
||||
}
|
||||
|
||||
String normalizedInput = normalizeDirectionText(userInput);
|
||||
for (Object directionObj : (List<Object>) rawDirections) {
|
||||
String direction = valueAsString(directionObj);
|
||||
if (direction == null) {
|
||||
continue;
|
||||
}
|
||||
if (normalizedInput.contains(normalizeDirectionText(direction))) {
|
||||
params.put("restAreaDirection", direction);
|
||||
log.info("[Web Chat][Domain Fetch] 사용자 발화에서 주유소 방향 보정 - direction: {}", direction);
|
||||
return fetchWebDomainData(intentType, params);
|
||||
}
|
||||
}
|
||||
return domainData;
|
||||
}
|
||||
|
||||
private Map<String, Object> fetchWebDomainData(String intentType, Map<String, Object> params) {
|
||||
String restAreaName = valueAsString(params.get("restAreaName"));
|
||||
if ("GASSTATION".equals(intentType)) {
|
||||
return dataPortalService.getGasstationInfo(
|
||||
restAreaName,
|
||||
valueAsString(params.get("restAreaDirection"))
|
||||
);
|
||||
}
|
||||
if ("BRAND_SHOP".equals(intentType)) {
|
||||
return dataPortalService.getBrandShopList(
|
||||
restAreaName,
|
||||
valueAsString(params.get("brand")),
|
||||
valueAsInteger(params.get("startIndex"))
|
||||
);
|
||||
}
|
||||
if ("REST_AREA_FOOD_LIST".equals(intentType)) {
|
||||
return dataPortalService.getFoodList(restAreaName);
|
||||
}
|
||||
Map<String, Object> unsupported = new HashMap<>();
|
||||
unsupported.put("status", false);
|
||||
unsupported.put("statusMsg", "지원하지 않는 외부 데이터 조회 유형입니다: " + intentType);
|
||||
return unsupported;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> callAgentApiWithDomainData(
|
||||
String userInput,
|
||||
String botId,
|
||||
String intentType,
|
||||
Map<String, Object> params,
|
||||
Map<String, Object> domainData,
|
||||
Map<String, Object> routeResponse
|
||||
) {
|
||||
try {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("question", userInput);
|
||||
body.put("botId", botId);
|
||||
body.put("intentType", intentType);
|
||||
body.put("params", params);
|
||||
body.put("domainData", domainData);
|
||||
|
||||
Map<String, Object> agentResponse = restTemplate.postForObject(
|
||||
chatbotApiUrl("v1/agent"),
|
||||
new HttpEntity<>(body, headers),
|
||||
Map.class
|
||||
);
|
||||
|
||||
if (agentResponse == null) {
|
||||
Map<String, Object> fallback = new HashMap<>();
|
||||
fallback.put("answer", "죄송합니다. 답변을 생성하지 못했습니다.");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
agentResponse.put("answer", agentResponse.get("llmAnswer"));
|
||||
agentResponse.put("intentType", intentType);
|
||||
agentResponse.put("params", params);
|
||||
agentResponse.put("domainData", agentResponse.get("domainData") != null ? agentResponse.get("domainData") : domainData);
|
||||
agentResponse.put("routeType", "web_domain");
|
||||
agentResponse.put("uiType", routeResponse.get("uiType"));
|
||||
return agentResponse;
|
||||
} catch (Exception e) {
|
||||
log.error("[Web Chat][Domain Fetch] /api/v1/agent 재호출 실패", e);
|
||||
Map<String, Object> fallback = new HashMap<>();
|
||||
fallback.put("answer", "죄송합니다. 서비스에 일시적인 장애가 발생하였습니다.");
|
||||
fallback.put("intentType", intentType);
|
||||
fallback.put("params", params);
|
||||
fallback.put("domainData", domainData);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private String chatbotApiUrl(String path) {
|
||||
return chatbotApiBaseUrl + path;
|
||||
}
|
||||
|
||||
private String normalizeBaseUrl(String baseUrl) {
|
||||
if (baseUrl == null || baseUrl.trim().isEmpty()) {
|
||||
return "http://172.16.180.130:8086/api/";
|
||||
}
|
||||
String trimmed = baseUrl.trim();
|
||||
return trimmed.endsWith("/") ? trimmed : trimmed + "/";
|
||||
}
|
||||
|
||||
private String normalizeDirectionText(Object value) {
|
||||
String text = valueAsString(value);
|
||||
if (text == null) {
|
||||
return "";
|
||||
}
|
||||
return text.replaceAll("\\s+", "")
|
||||
.replace("방향", "")
|
||||
.replace("행", "");
|
||||
}
|
||||
|
||||
private String valueAsString(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
return text.isEmpty() || "null".equalsIgnoreCase(text) ? null : text;
|
||||
}
|
||||
|
||||
private Integer valueAsInteger(Object value) {
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).intValue();
|
||||
}
|
||||
String text = valueAsString(value);
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(text);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package com.ex.chatbot.kakao.web;
|
||||
|
||||
import com.ex.chatbot.kakao.web.dto.WebAskRequest;
|
||||
import com.ex.chatbot.kakao.web.dto.WebAskResponse;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/web")
|
||||
@RequiredArgsConstructor
|
||||
public class WebChatController {
|
||||
|
||||
private static final long SSE_TIMEOUT_MS = 120_000L;
|
||||
|
||||
private final WebChatService webChatService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@PostMapping("/ask")
|
||||
public WebAskResponse ask(@RequestBody WebAskRequest request) {
|
||||
return webChatService.ask(request);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/ask/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter askStream(@RequestBody WebAskRequest request) {
|
||||
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
|
||||
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.name("status")
|
||||
.data("{\"phase\":\"generating\",\"message\":\"답변 생성 중…\"}"));
|
||||
|
||||
WebAskResponse response = webChatService.ask(request);
|
||||
emitter.send(SseEmitter.event()
|
||||
.name("done")
|
||||
.data(objectMapper.writeValueAsString(response)));
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
log.error("[Web Chat] SSE 처리 오류", e);
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.name("error")
|
||||
.data("{\"message\":\"서비스에 일시적인 장애가 발생하였습니다.\"}"));
|
||||
} catch (Exception ignored) {
|
||||
// ignore secondary failure
|
||||
}
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
@GetMapping(value = "/openapi.json", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public Map<String, Object> openApiSpec() {
|
||||
Map<String, Object> askStreamPost = Map.of("post", Map.of("summary", "Web chat ask stream"));
|
||||
Map<String, Object> askPost = Map.of("post", Map.of("summary", "Web chat ask"));
|
||||
Map<String, Object> paths = new LinkedHashMap<>();
|
||||
paths.put("/ask/stream", askStreamPost);
|
||||
paths.put("/ask", askPost);
|
||||
|
||||
Map<String, Object> spec = new LinkedHashMap<>();
|
||||
spec.put("openapi", "3.0.0");
|
||||
spec.put("info", Map.of("title", "kakaoChatbotSkill Web API", "version", "1.0.0"));
|
||||
spec.put("paths", paths);
|
||||
return spec;
|
||||
}
|
||||
}
|
||||
+610
@@ -0,0 +1,610 @@
|
||||
package com.ex.chatbot.kakao.web;
|
||||
|
||||
import com.ex.chatbot.kakao.web.dto.WebActionButtonDto;
|
||||
import com.ex.chatbot.kakao.web.dto.WebAskResponse;
|
||||
import com.ex.chatbot.kakao.web.dto.WebListCardDto;
|
||||
import com.ex.chatbot.kakao.web.dto.WebListCardItemDto;
|
||||
import com.ex.chatbot.kakao.web.dto.WebQuickReplyDto;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.text.DecimalFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class WebChatResponseMapper {
|
||||
|
||||
private static final String FARELIST_PLAIN_URL = "https://chatbot.ex.co.kr/images/kakao/default/blackBanner.png";
|
||||
private static final String FARELIST_1 = "https://chatbot.ex.co.kr/images/kakao/fare/1_type.png";
|
||||
private static final String FARELIST_2 = "https://chatbot.ex.co.kr/images/kakao/fare/2_type.png";
|
||||
private static final String FARELIST_3 = "https://chatbot.ex.co.kr/images/kakao/fare/3_type.png";
|
||||
private static final String FARELIST_4 = "https://chatbot.ex.co.kr/images/kakao/fare/4_type.png";
|
||||
private static final String FARELIST_5 = "https://chatbot.ex.co.kr/images/kakao/fare/5_type.png";
|
||||
private static final String RESTAREA_LIST_BANNER_URL = "https://chatbot.ex.co.kr/images/kakao/default/blackBanner.png";
|
||||
private static final String GASSTATION_GASOLINE_URL = "https://chatbot.ex.co.kr/images/kakao/restArea/gasoline.png";
|
||||
private static final String GASSTATION_DIESEL_URL = "https://chatbot.ex.co.kr/images/kakao/restArea/diesel.png";
|
||||
private static final String GASSTATION_LPG_URL = "https://chatbot.ex.co.kr/images/kakao/restArea/lpg.png";
|
||||
private static final String BRAND_IMAGE_URL = "https://chatbot.ex.co.kr/images/logo/";
|
||||
private static final int LISTCARD_MAX = 5;
|
||||
private static final int FOOD_MENU_DISPLAY_LIMIT = 8;
|
||||
|
||||
public WebAskResponse toResponse(Map<String, Object> raw, String botId) {
|
||||
Map<String, Object> response = raw != null ? raw : Collections.emptyMap();
|
||||
List<WebListCardDto> listCards = new ArrayList<>();
|
||||
List<WebQuickReplyDto> quickReplies = new ArrayList<>();
|
||||
List<WebActionButtonDto> actionButtons = new ArrayList<>();
|
||||
|
||||
appendRawQuickReplies(quickReplies, response);
|
||||
appendIcCandidateQuickReplies(quickReplies, response);
|
||||
appendFaqActionButtons(actionButtons, response);
|
||||
appendDomainUi(listCards, quickReplies, actionButtons, response);
|
||||
|
||||
String answer = answerOf(response);
|
||||
if (answer.isBlank() && listCards.isEmpty() && actionButtons.isEmpty() && quickReplies.isEmpty()) {
|
||||
answer = "죄송합니다. 답변을 생성하지 못했습니다.";
|
||||
}
|
||||
|
||||
List<Map<String, Object>> references = referencesOf(response);
|
||||
Map<String, Object> domainData = mapOf(response.get("domainData"));
|
||||
Map<String, Object> params = mapOf(response.get("params"));
|
||||
|
||||
return WebAskResponse.builder()
|
||||
.status(true)
|
||||
.answer(answer)
|
||||
.botId(botId)
|
||||
.routeType(valueAsString(response.get("routeType")))
|
||||
.intentType(valueAsString(response.get("intentType")))
|
||||
.uiType(valueAsString(response.get("uiType")))
|
||||
.references(references)
|
||||
.num_references(references.size())
|
||||
.params(params)
|
||||
.domainData(domainData)
|
||||
.rawData(new HashMap<>(response))
|
||||
.listCards(listCards)
|
||||
.quickReplies(quickReplies)
|
||||
.actionButtons(actionButtons)
|
||||
.build();
|
||||
}
|
||||
|
||||
private String answerOf(Map<String, Object> response) {
|
||||
Object answerObj = response.get("answer") != null ? response.get("answer") : response.get("llmAnswer");
|
||||
if (answerObj == null) {
|
||||
return "";
|
||||
}
|
||||
return String.valueOf(answerObj)
|
||||
.replaceAll(" \\n", "\n")
|
||||
.replaceAll("\\n{3,}", "\n\n")
|
||||
.replaceAll(" *\\n *", "\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Map<String, Object>> referencesOf(Map<String, Object> response) {
|
||||
Object refs = response.get("references");
|
||||
if (!(refs instanceof List)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Object ref : (List<Object>) refs) {
|
||||
if (ref instanceof Map) {
|
||||
result.add((Map<String, Object>) ref);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void appendDomainUi(
|
||||
List<WebListCardDto> listCards,
|
||||
List<WebQuickReplyDto> quickReplies,
|
||||
List<WebActionButtonDto> actionButtons,
|
||||
Map<String, Object> response
|
||||
) {
|
||||
String intentType = valueAsString(response.get("intentType"));
|
||||
String uiType = valueAsString(response.get("uiType"));
|
||||
|
||||
if ("FARE_SEARCH".equals(intentType) || "FARE_CARD".equals(uiType)) {
|
||||
appendFareSearch(listCards, quickReplies, response);
|
||||
return;
|
||||
}
|
||||
if ("FARE_UNPAID".equals(intentType)) {
|
||||
appendUnpaidActions(actionButtons, response);
|
||||
return;
|
||||
}
|
||||
if ("FARE_REFUND".equals(intentType)) {
|
||||
appendRefundActions(actionButtons, response);
|
||||
return;
|
||||
}
|
||||
if ("IC_TEL".equals(intentType) || "PHONE_CARD".equals(uiType)) {
|
||||
appendIcTelAction(actionButtons, response);
|
||||
return;
|
||||
}
|
||||
if ("GASSTATION".equals(intentType) || "GASSTATION_CARD".equals(uiType)) {
|
||||
appendGasstationCards(listCards, response);
|
||||
return;
|
||||
}
|
||||
if ("BRAND_SHOP".equals(intentType) || "BRAND_SHOP_CARD".equals(uiType)) {
|
||||
appendBrandShopCards(listCards, response);
|
||||
return;
|
||||
}
|
||||
if ("REST_AREA_FOOD_LIST".equals(intentType) || "FOOD_LIST_CARD".equals(uiType)) {
|
||||
appendFoodCards(listCards, response);
|
||||
return;
|
||||
}
|
||||
if ("REST_AREA_PRICE".equals(intentType) || "PRICE_CARD".equals(uiType)) {
|
||||
appendRestAreaPriceCards(listCards, response);
|
||||
return;
|
||||
}
|
||||
if ("TROAD_SEARCH_ROUTE".equals(intentType)) {
|
||||
appendTroadSearchRouteCandidates(quickReplies, response);
|
||||
return;
|
||||
}
|
||||
if ("TROAD_CONGESTION".equals(intentType)) {
|
||||
appendRouteCandidateQuickReplies(quickReplies, response, "정체정보");
|
||||
return;
|
||||
}
|
||||
if ("TROAD_BREAKING_NEWS".equals(intentType)) {
|
||||
appendRouteCandidateQuickReplies(quickReplies, response, "교통속보");
|
||||
}
|
||||
}
|
||||
|
||||
private void appendFaqActionButtons(List<WebActionButtonDto> actionButtons, Map<String, Object> response) {
|
||||
Object rawUrls = response.get("faqUrls");
|
||||
if (!(rawUrls instanceof List)) {
|
||||
return;
|
||||
}
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
for (Object rawUrl : (List<?>) rawUrls) {
|
||||
String url = valueAsString(rawUrl);
|
||||
if (!isHttpUrl(url) || !seen.add(url)) {
|
||||
continue;
|
||||
}
|
||||
actionButtons.add(webLink(seen.size() == 1 ? "FAQ 원문 보기" : "FAQ 원문 " + seen.size(), url));
|
||||
if (seen.size() >= 3) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void appendRawQuickReplies(List<WebQuickReplyDto> quickReplies, Map<String, Object> response) {
|
||||
Object rawQuickReplies = response.get("quickReplies");
|
||||
if (!(rawQuickReplies instanceof List)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<String> existingMessages = quickReplies.stream()
|
||||
.map(WebQuickReplyDto::getMessage)
|
||||
.collect(Collectors.toSet());
|
||||
for (Object rawQuickReply : (List<Object>) rawQuickReplies) {
|
||||
String label = null;
|
||||
String message = null;
|
||||
if (rawQuickReply instanceof Map) {
|
||||
Map<String, Object> quickReplyMap = (Map<String, Object>) rawQuickReply;
|
||||
label = firstNonBlank(
|
||||
valueAsString(quickReplyMap.get("label")),
|
||||
valueAsString(quickReplyMap.get("title")),
|
||||
valueAsString(quickReplyMap.get("message")),
|
||||
valueAsString(quickReplyMap.get("messageText"))
|
||||
);
|
||||
message = firstNonBlank(
|
||||
valueAsString(quickReplyMap.get("message")),
|
||||
valueAsString(quickReplyMap.get("messageText")),
|
||||
label
|
||||
);
|
||||
} else {
|
||||
label = valueAsString(rawQuickReply);
|
||||
message = label;
|
||||
}
|
||||
|
||||
if (label == null || message == null || !existingMessages.add(message)) {
|
||||
continue;
|
||||
}
|
||||
quickReplies.add(quickReply(label, message));
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void appendIcCandidateQuickReplies(List<WebQuickReplyDto> quickReplies, Map<String, Object> response) {
|
||||
Object rawCandidates = response.get("icCandidates");
|
||||
if (!(rawCandidates instanceof List)) {
|
||||
return;
|
||||
}
|
||||
Set<String> existingLabels = quickReplies.stream()
|
||||
.map(WebQuickReplyDto::getLabel)
|
||||
.collect(Collectors.toSet());
|
||||
int insertIndex = 0;
|
||||
for (Object candidateObj : (List<Object>) rawCandidates) {
|
||||
if (!(candidateObj instanceof Map)) {
|
||||
continue;
|
||||
}
|
||||
String icName = valueAsString(((Map<String, Object>) candidateObj).get("icName"));
|
||||
if (icName == null || existingLabels.contains(icName)) {
|
||||
continue;
|
||||
}
|
||||
quickReplies.add(insertIndex++, quickReply(icName, icName));
|
||||
existingLabels.add(icName);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendFareSearch(
|
||||
List<WebListCardDto> listCards,
|
||||
List<WebQuickReplyDto> quickReplies,
|
||||
Map<String, Object> response
|
||||
) {
|
||||
Map<String, Object> domainData = mapOf(response.get("domainData"));
|
||||
Map<String, Object> fareData = mapOf(domainData.get("fareData"));
|
||||
String fareType = valueAsString(fareData.get("type"));
|
||||
if ("open_from".equals(fareType)) {
|
||||
appendOpenFareQuickReplies(quickReplies, fareData, true);
|
||||
return;
|
||||
}
|
||||
if ("open_to".equals(fareType)) {
|
||||
appendOpenFareQuickReplies(quickReplies, fareData, false);
|
||||
return;
|
||||
}
|
||||
if (!"exact".equals(fareType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> params = mapOf(response.get("params"));
|
||||
String fromIc = params.get("fromIc") != null ? String.valueOf(params.get("fromIc")) : "출발지";
|
||||
String toIc = params.get("toIc") != null ? String.valueOf(params.get("toIc")) : "도착지";
|
||||
List<WebListCardItemDto> items = new ArrayList<>();
|
||||
items.add(item("1종 : " + toInt(fareData.get("fare1")) + "원\n* 1종(경차) : " + toInt(fareData.get("fare1Small")) + "원",
|
||||
"승용차, 소형승합차/화물차, 경차", FARELIST_1));
|
||||
items.add(item("2종 : " + toInt(fareData.get("fare2")) + "원", "중형승합차, 중형화물차", FARELIST_2));
|
||||
items.add(item("3종 : " + toInt(fareData.get("fare3")) + "원", "대형승합차, 2축 대형화물차", FARELIST_3));
|
||||
items.add(item("4종 : " + toInt(fareData.get("fare4")) + "원", "3축 대형화물차", FARELIST_4));
|
||||
items.add(item("5종 : " + toInt(fareData.get("fare5")) + "원", "4축 이상 특수 화물차", FARELIST_5));
|
||||
|
||||
listCards.add(WebListCardDto.builder()
|
||||
.title(fromIc + " - " + toIc + " 통행요금입니다.")
|
||||
.headerImageUrl(FARELIST_PLAIN_URL)
|
||||
.items(items)
|
||||
.fareBreakdown(mapOf(fareData.get("webBreakdown")))
|
||||
.build());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void appendOpenFareQuickReplies(List<WebQuickReplyDto> quickReplies, Map<String, Object> fareData, boolean openFrom) {
|
||||
Object outIcsObj = fareData.get("outIcs");
|
||||
if (!(outIcsObj instanceof List)) {
|
||||
return;
|
||||
}
|
||||
String fixedIc = openFrom ? valueAsString(fareData.get("toIc")) : valueAsString(fareData.get("fromIc"));
|
||||
if (fixedIc == null) {
|
||||
return;
|
||||
}
|
||||
int insertIndex = 0;
|
||||
for (String outIc : (List<String>) outIcsObj) {
|
||||
if (outIc == null || outIc.trim().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String label = openFrom ? outIc + "에서 " + fixedIc : fixedIc + "에서 " + outIc;
|
||||
quickReplies.add(insertIndex++, quickReply(label, label + " 통행요금"));
|
||||
}
|
||||
}
|
||||
|
||||
private void appendUnpaidActions(List<WebActionButtonDto> actionButtons, Map<String, Object> response) {
|
||||
Map<String, Object> domainData = mapOf(response.get("domainData"));
|
||||
if (!Boolean.TRUE.equals(domainData.get("status")) || toLong(domainData.get("sumAmt")) < 100) {
|
||||
return;
|
||||
}
|
||||
String carNo = valueAsString(domainData.get("carNo"));
|
||||
if (carNo == null) {
|
||||
carNo = valueAsString(mapOf(response.get("params")).get("carNo"));
|
||||
}
|
||||
if (carNo == null) {
|
||||
return;
|
||||
}
|
||||
String noMemberUrl = "https://www.hipass.co.kr/nomem/NoMemberNopaySearch.do?car_no=" + carNo
|
||||
+ "&number=" + carNo + "&gubun=chatbot";
|
||||
actionButtons.add(osLink("비회원 미납 납부", noMemberUrl, noMemberUrl, noMemberUrl));
|
||||
actionButtons.add(osLink("고속도로 통행료 앱",
|
||||
"https://m.hipass.co.kr/runApp.jsp",
|
||||
"intent://details?id=com.ex.hipass_app#Intent;scheme=market;package=com.android.vending;end",
|
||||
"exhipassapp://open?url=/noMemUnpaidToll/noMemUnpaidTollCert.do?carNo=" + carNo));
|
||||
actionButtons.add(osLink("고속도로 통행료 홈페이지",
|
||||
"https://www.hipass.co.kr/comm/lginpg.do",
|
||||
"https://www.hipass.co.kr/comm/lginpg.do",
|
||||
"https://www.hipass.co.kr/comm/lginpg.do"));
|
||||
}
|
||||
|
||||
private void appendRefundActions(List<WebActionButtonDto> actionButtons, Map<String, Object> response) {
|
||||
Map<String, Object> domainData = mapOf(response.get("domainData"));
|
||||
if (!Boolean.TRUE.equals(domainData.get("status"))) {
|
||||
return;
|
||||
}
|
||||
String carNo = valueAsString(domainData.get("carNo"));
|
||||
if (carNo == null) {
|
||||
carNo = valueAsString(mapOf(response.get("params")).get("carNo"));
|
||||
}
|
||||
if (carNo == null) {
|
||||
carNo = "";
|
||||
}
|
||||
actionButtons.add(osLink("환불 통행료 확인하기",
|
||||
"https://www.hipass.co.kr/nopay/SearchRepayApply.do#mab",
|
||||
"https://www.hipass.co.kr/nopay/SearchRepayApply.do#mab",
|
||||
"https://www.hipass.co.kr/nopay/SearchRepayApply.do#mab"));
|
||||
actionButtons.add(osLink("고속도로 통행료 앱",
|
||||
"https://m.hipass.co.kr/runApp.jsp",
|
||||
"intent://details?id=com.ex.hipass_app#Intent;scheme=market;package=com.android.vending;end",
|
||||
"exhipassapp://open?url=/noMemUnpaidToll/noMemUnpaidTollCert.do?carNo=" + carNo));
|
||||
}
|
||||
|
||||
private void appendIcTelAction(List<WebActionButtonDto> actionButtons, Map<String, Object> response) {
|
||||
Map<String, Object> domainData = mapOf(response.get("domainData"));
|
||||
if (!Boolean.TRUE.equals(domainData.get("status"))) {
|
||||
return;
|
||||
}
|
||||
String tel = valueAsString(domainData.get("tel"));
|
||||
if (tel == null) {
|
||||
return;
|
||||
}
|
||||
actionButtons.add(WebActionButtonDto.builder()
|
||||
.label("전화하기")
|
||||
.action("phone")
|
||||
.phoneNumber(tel)
|
||||
.url("tel:" + tel.replaceAll("[^0-9+]", ""))
|
||||
.build());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void appendGasstationCards(List<WebListCardDto> listCards, Map<String, Object> response) {
|
||||
Map<String, Object> domainData = mapOf(response.get("domainData"));
|
||||
if (!Boolean.TRUE.equals(domainData.get("status")) || !(domainData.get("list") instanceof List)) {
|
||||
return;
|
||||
}
|
||||
for (Map<String, Object> gasInfo : (List<Map<String, Object>>) domainData.get("list")) {
|
||||
String gasDirection = gasstationDisplayName(domainData, gasInfo);
|
||||
List<WebListCardItemDto> items = new ArrayList<>();
|
||||
items.add(item(priceOrNone(gasInfo.get("gasolinePrice")), gasDirection, GASSTATION_GASOLINE_URL));
|
||||
items.add(item(priceOrNone(gasInfo.get("diselPrice")), gasDirection, GASSTATION_DIESEL_URL));
|
||||
items.add(item("Y".equals(String.valueOf(gasInfo.getOrDefault("lpgYn", "N")))
|
||||
? String.valueOf(gasInfo.getOrDefault("lpgPrice", "X"))
|
||||
: "미운영", gasDirection, GASSTATION_LPG_URL));
|
||||
listCards.add(WebListCardDto.builder()
|
||||
.title(gasDirection + " 주유정보입니다.")
|
||||
.headerImageUrl(RESTAREA_LIST_BANNER_URL)
|
||||
.items(items)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void appendBrandShopCards(List<WebListCardDto> listCards, Map<String, Object> response) {
|
||||
Map<String, Object> domainData = mapOf(response.get("domainData"));
|
||||
if (!Boolean.TRUE.equals(domainData.get("status")) || !(domainData.get("list") instanceof List)) {
|
||||
return;
|
||||
}
|
||||
List<Map<String, Object>> shops = (List<Map<String, Object>>) domainData.get("list");
|
||||
String restArea = String.valueOf(domainData.getOrDefault("restAreaName", "휴게소"));
|
||||
String brand = valueAsString(domainData.get("brand"));
|
||||
int count = shops.size();
|
||||
int listCardNum = count > LISTCARD_MAX * 3 ? 3 : count / LISTCARD_MAX + (count % LISTCARD_MAX == 0 ? 0 : 1);
|
||||
String title = brand != null ? restArea + " 휴게소 " + brand + " 매장 정보입니다." : restArea + " 휴게소 매장 정보입니다.";
|
||||
for (int i = 0; i < listCardNum; i++) {
|
||||
List<WebListCardItemDto> items = new ArrayList<>();
|
||||
int endNum = Math.min((i + 1) * LISTCARD_MAX, count);
|
||||
for (int j = i * LISTCARD_MAX; j < endNum; j++) {
|
||||
Map<String, Object> shop = shops.get(j);
|
||||
String description = shop.get("stime") + "~" + shop.get("etime") + " "
|
||||
+ String.valueOf(shop.get("stdRestNm")).replace("휴게소", "");
|
||||
items.add(item(String.valueOf(shop.get("brdName")), description, BRAND_IMAGE_URL + shop.get("brdCode") + ".png"));
|
||||
}
|
||||
listCards.add(WebListCardDto.builder().title(title).headerImageUrl(RESTAREA_LIST_BANNER_URL).items(items).build());
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void appendFoodCards(List<WebListCardDto> listCards, Map<String, Object> response) {
|
||||
Map<String, Object> domainData = mapOf(response.get("domainData"));
|
||||
Object msgMapObj = domainData.get("msgMap");
|
||||
if (!Boolean.TRUE.equals(domainData.get("status")) || !(msgMapObj instanceof Map)) {
|
||||
return;
|
||||
}
|
||||
for (Map.Entry<String, String> entry : ((Map<String, String>) msgMapObj).entrySet()) {
|
||||
List<WebListCardItemDto> items = Arrays.stream((entry.getValue() != null ? entry.getValue() : "").split("\\r?\\n"))
|
||||
.map(String::trim)
|
||||
.filter(line -> !line.isEmpty())
|
||||
.limit(FOOD_MENU_DISPLAY_LIMIT)
|
||||
.map(line -> item(line, null, null))
|
||||
.collect(Collectors.toList());
|
||||
listCards.add(WebListCardDto.builder()
|
||||
.title(entry.getKey() + " 주요 메뉴입니다.")
|
||||
.items(items)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void appendRestAreaPriceCards(List<WebListCardDto> listCards, Map<String, Object> response) {
|
||||
Map<String, Object> domainData = mapOf(response.get("domainData"));
|
||||
Object priceInfoObj = domainData.get("priceInfo");
|
||||
if (!Boolean.TRUE.equals(domainData.get("status")) || !(priceInfoObj instanceof Map)) {
|
||||
return;
|
||||
}
|
||||
DecimalFormat formatter = new DecimalFormat("#,###");
|
||||
Map<String, List<Map<String, Object>>> priceInfoByRestArea = (Map<String, List<Map<String, Object>>>) priceInfoObj;
|
||||
for (String restName : priceInfoByRestArea.keySet()) {
|
||||
List<WebListCardItemDto> items = new ArrayList<>();
|
||||
for (Map<String, Object> priceItem : priceInfoByRestArea.get(restName)) {
|
||||
String snackGb = String.valueOf(priceItem.get("snackGb"));
|
||||
String title = (snackGb.endsWith("류") ? snackGb : snackGb + "류");
|
||||
String description = formatter.format(toInt(priceItem.get("minCost"))) + "원 / "
|
||||
+ formatter.format(toInt(priceItem.get("maxCost"))) + "원 / "
|
||||
+ formatter.format(toInt(priceItem.get("avgCost"))) + "원";
|
||||
items.add(item(title, description, null));
|
||||
}
|
||||
listCards.add(WebListCardDto.builder()
|
||||
.title(restName + " 음식 가격입니다. (최저/최고/평균)")
|
||||
.items(items)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void appendRouteCandidateQuickReplies(List<WebQuickReplyDto> quickReplies, Map<String, Object> response, String querySuffix) {
|
||||
Map<String, Object> domainData = mapOf(response.get("domainData"));
|
||||
Object rawRoutes = domainData.get("RouteQuickReplyInfo");
|
||||
if (!(rawRoutes instanceof List)) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> params = mapOf(response.get("params"));
|
||||
String routeName = valueAsString(params.get("routeName"));
|
||||
for (Map<String, Object> route : (List<Map<String, Object>>) rawRoutes) {
|
||||
String routeNameQuick = valueAsString(route.get("ROUTENM"));
|
||||
if (routeNameQuick == null || routeNameQuick.equals(routeName)) {
|
||||
continue;
|
||||
}
|
||||
String message = routeNameQuick + " " + querySuffix;
|
||||
quickReplies.add(quickReply(message, message));
|
||||
}
|
||||
}
|
||||
|
||||
private void appendTroadSearchRouteCandidates(List<WebQuickReplyDto> quickReplies, Map<String, Object> response) {
|
||||
Map<String, Object> domainData = mapOf(response.get("domainData"));
|
||||
if (Boolean.TRUE.equals(domainData.get("status"))) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> params = mapOf(response.get("params"));
|
||||
String fromIc = valueAsString(params.get("fromIc"));
|
||||
String toIc = valueAsString(params.get("toIc"));
|
||||
if (fromIc == null || toIc == null) {
|
||||
return;
|
||||
}
|
||||
if ("대구".equals(fromIc)) {
|
||||
quickReplies.add(quickReply("동대구에서 " + toIc + " 교통정보", "동대구에서 " + toIc + " 교통정보"));
|
||||
quickReplies.add(quickReply("서대구에서 " + toIc + " 교통정보", "서대구에서 " + toIc + " 교통정보"));
|
||||
quickReplies.add(quickReply("남대구에서 " + toIc + " 교통정보", "남대구에서 " + toIc + " 교통정보"));
|
||||
quickReplies.add(quickReply("북대구에서 " + toIc + " 교통정보", "북대구에서 " + toIc + " 교통정보"));
|
||||
} else if ("대구".equals(toIc)) {
|
||||
quickReplies.add(quickReply(fromIc + "에서 동대구 교통정보", fromIc + "에서 동대구 교통정보"));
|
||||
quickReplies.add(quickReply(fromIc + "에서 서대구 교통정보", fromIc + "에서 서대구 교통정보"));
|
||||
quickReplies.add(quickReply(fromIc + "에서 남대구 교통정보", fromIc + "에서 남대구 교통정보"));
|
||||
quickReplies.add(quickReply(fromIc + "에서 북대구 교통정보", fromIc + "에서 북대구 교통정보"));
|
||||
}
|
||||
}
|
||||
|
||||
private WebActionButtonDto webLink(String label, String url) {
|
||||
return WebActionButtonDto.builder()
|
||||
.label(label)
|
||||
.action("webLink")
|
||||
.url(url)
|
||||
.webUrl(url)
|
||||
.build();
|
||||
}
|
||||
|
||||
private WebActionButtonDto osLink(String label, String pcUrl, String androidUrl, String iosUrl) {
|
||||
return WebActionButtonDto.builder()
|
||||
.label(label)
|
||||
.action("osLink")
|
||||
.url(pcUrl)
|
||||
.pcUrl(pcUrl)
|
||||
.webUrl(pcUrl)
|
||||
.androidUrl(androidUrl)
|
||||
.iosUrl(iosUrl)
|
||||
.build();
|
||||
}
|
||||
|
||||
private WebQuickReplyDto quickReply(String label, String message) {
|
||||
return WebQuickReplyDto.builder().label(label).message(message).build();
|
||||
}
|
||||
|
||||
private WebListCardItemDto item(String title, String description, String imageUrl) {
|
||||
return WebListCardItemDto.builder()
|
||||
.title(title)
|
||||
.description(description)
|
||||
.imageUrl(imageUrl)
|
||||
.build();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> mapOf(Object value) {
|
||||
return value instanceof Map ? (Map<String, Object>) value : Collections.emptyMap();
|
||||
}
|
||||
|
||||
private String gasstationDisplayName(Map<String, Object> domainData, Map<String, Object> gasInfo) {
|
||||
String serviceAreaName = valueAsString(gasInfo.get("serviceAreaName"));
|
||||
if (serviceAreaName != null) {
|
||||
return serviceAreaName.replace("주유소", "").replace("휴게소", "");
|
||||
}
|
||||
String restAreaName = valueAsString(domainData.get("restAreaName"));
|
||||
String direction = valueAsString(gasInfo.get("direction"));
|
||||
if (restAreaName != null && direction != null) {
|
||||
return restAreaName + "(" + direction + ")";
|
||||
}
|
||||
return "주유소";
|
||||
}
|
||||
|
||||
private String priceOrNone(Object raw) {
|
||||
String value = String.valueOf(raw != null ? raw : "X");
|
||||
return "X".equals(value) ? "주유금액 정보 없음" : value;
|
||||
}
|
||||
|
||||
private String valueAsString(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
return text.isEmpty() || "null".equalsIgnoreCase(text) ? null : text;
|
||||
}
|
||||
|
||||
private boolean isHttpUrl(String url) {
|
||||
return url != null && (url.startsWith("http://") || url.startsWith("https://"));
|
||||
}
|
||||
|
||||
private String firstNonBlank(String... values) {
|
||||
for (String value : values) {
|
||||
if (value != null && !value.trim().isEmpty()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private int toInt(Object value) {
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).intValue();
|
||||
}
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(String.valueOf(value));
|
||||
} catch (NumberFormatException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private long toLong(Object value) {
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).longValue();
|
||||
}
|
||||
if (value == null) {
|
||||
return 0L;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(String.valueOf(value));
|
||||
} catch (NumberFormatException e) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private String encodeUrl(String url) {
|
||||
try {
|
||||
return java.net.URLEncoder.encode(url, java.nio.charset.StandardCharsets.UTF_8.toString());
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.ex.chatbot.kakao.web;
|
||||
|
||||
import com.ex.chatbot.kakao.web.dto.WebAskRequest;
|
||||
import com.ex.chatbot.kakao.web.dto.WebAskResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WebChatService {
|
||||
|
||||
private final WebChatAgentService webChatAgentService;
|
||||
private final WebChatResponseMapper responseMapper;
|
||||
|
||||
public WebAskResponse ask(WebAskRequest request) {
|
||||
String query = request != null ? request.getQuery() : null;
|
||||
String botId = request != null && request.getBotId() != null && !request.getBotId().isBlank()
|
||||
? request.getBotId()
|
||||
: "web-anonymous";
|
||||
|
||||
if (query == null || query.isBlank()) {
|
||||
return WebAskResponse.builder()
|
||||
.status(false)
|
||||
.answer("질문 내용을 입력해주세요.")
|
||||
.botId(botId)
|
||||
.build();
|
||||
}
|
||||
|
||||
log.info("[Web Chat] 질문 수신 - botId: {}, query: '{}'", botId, query);
|
||||
|
||||
String normalizedQuestion = normalizeQuestion(query);
|
||||
try {
|
||||
Map<String, Object> rawResponse = webChatAgentService.ask(normalizedQuestion, botId);
|
||||
WebAskResponse response = responseMapper.toResponse(rawResponse, botId);
|
||||
log.info("[Web Chat] 답변 생성 완료 - botId: {}, textLength: {}, listCards: {}, quickReplies: {}, actionButtons: {}, numReferences: {}, routeType: {}, intentType: {}",
|
||||
botId,
|
||||
response.getAnswer() != null ? response.getAnswer().length() : 0,
|
||||
response.getListCards() != null ? response.getListCards().size() : 0,
|
||||
response.getQuickReplies() != null ? response.getQuickReplies().size() : 0,
|
||||
response.getActionButtons() != null ? response.getActionButtons().size() : 0,
|
||||
response.getNum_references(),
|
||||
response.getRouteType(),
|
||||
response.getIntentType());
|
||||
return response;
|
||||
} catch (Exception e) {
|
||||
log.error("[Web Chat] 처리 오류", e);
|
||||
return WebAskResponse.builder()
|
||||
.status(false)
|
||||
.answer("죄송합니다. 서비스에 일시적인 장애가 발생하였습니다. 나중에 다시 시도해주세요.")
|
||||
.botId(botId)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeQuestion(String query) {
|
||||
String normalized = query.trim();
|
||||
if (normalized.startsWith("자체개발")) {
|
||||
return normalized.substring("자체개발".length()).trim();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.ex.chatbot.kakao.web.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class WebActionButtonDto {
|
||||
private String label;
|
||||
private String action;
|
||||
private String url;
|
||||
private String webUrl;
|
||||
private String pcUrl;
|
||||
private String mobileUrl;
|
||||
private String androidUrl;
|
||||
private String iosUrl;
|
||||
private String phoneNumber;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.ex.chatbot.kakao.web.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class WebAskRequest {
|
||||
private String query;
|
||||
private String botId;
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.ex.chatbot.kakao.web.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class WebAskResponse {
|
||||
private boolean status;
|
||||
private String answer;
|
||||
private String botId;
|
||||
private String routeType;
|
||||
private String intentType;
|
||||
private String uiType;
|
||||
private Integer num_references;
|
||||
private List<Map<String, Object>> references;
|
||||
private Map<String, Object> params;
|
||||
private Map<String, Object> domainData;
|
||||
private Map<String, Object> rawData;
|
||||
private List<WebListCardDto> listCards;
|
||||
private List<WebQuickReplyDto> quickReplies;
|
||||
private List<WebActionButtonDto> actionButtons;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.ex.chatbot.kakao.web.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class WebListCardDto {
|
||||
private String title;
|
||||
private String headerImageUrl;
|
||||
private List<WebListCardItemDto> items;
|
||||
private Map<String, Object> fareBreakdown;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.ex.chatbot.kakao.web.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class WebListCardItemDto {
|
||||
private String title;
|
||||
private String description;
|
||||
private String imageUrl;
|
||||
private String linkUrl;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.ex.chatbot.kakao.web.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class WebQuickReplyDto {
|
||||
private String label;
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# =============================================================================
|
||||
# kakaoChatbotSkill — 카카오 웹훅 / nginx 진입 (웹 서버, WAS와 분리)
|
||||
# WAS(172.16.180.130): chatbotApi :8086, exAiChatBot :28012, chatbotAdmin :8087
|
||||
# LLM 게이트웨이: 172.16.163.96 (HTTPS, llm-ai.ex.co.kr 등)
|
||||
# =============================================================================
|
||||
|
||||
server:
|
||||
port: 8083
|
||||
http2:
|
||||
enabled: true
|
||||
|
||||
restTemplate:
|
||||
factory:
|
||||
readTimeout: 5000
|
||||
connectTimeout: 3000
|
||||
httpClient:
|
||||
maxConnTotal: 100
|
||||
maxConnPerRoute: 5
|
||||
|
||||
# Skill → WAS (legacy /llm-agent, domain-only 등)
|
||||
chatbot-api:
|
||||
base-url: ${CHATBOT_API_BASE_URL:http://172.16.180.130:8086/api/}
|
||||
|
||||
# data.ex.co.kr — WAS에서 접근 불가, Skill에서 직접 호출
|
||||
data-portal:
|
||||
base-url: https://data.ex.co.kr/openapi
|
||||
api-key: "6123592782"
|
||||
max-rows: 100
|
||||
|
||||
# WAS Agent tool → Skill internal 호출 시 인증 (WAS와 동일 키)
|
||||
internal:
|
||||
tool:
|
||||
api-key: ${INTERNAL_TOOL_API_KEY:}
|
||||
|
||||
# Agent 모드: Skill → RAG /agent/chat (RAG는 WAS 서버 172.16.180.130)
|
||||
agent:
|
||||
mode: ${AGENT_MODE:legacy}
|
||||
fallback-legacy: true
|
||||
chat:
|
||||
url: ${RAG_AGENT_CHAT_URL:http://172.16.180.130:28012/agent/chat}
|
||||
timeout-ms: ${AGENT_CHAT_TIMEOUT_MS:100000}
|
||||
|
||||
# 카카오 폴백 → 웹 챗봇 리다이렉트 URL
|
||||
chatbot-app:
|
||||
url: ${CHATBOT_APP_URL:https://chatbot.ex.co.kr/chatbot/app/}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration scan="true" scanPeriod="30 seconds">
|
||||
|
||||
<!--property resource="resource.properties"/-->
|
||||
<property name="LOG_DIR" value="./logs" />
|
||||
|
||||
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern> %d{HH:mm:ss.SSS,Asia/Seoul} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_DIR}/exKakaoChat.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_DIR}/exKakaoChat.%d{yyyy-MM-dd,Asia/Seoul}.log</fileNamePattern>
|
||||
<maxHistory>90</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{HH:mm:ss.SSS,Asia/Seoul} %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
<appender name="HIKARI" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_DIR}/Hikari.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_DIR}/Hikari.%d{yyyy-MM-dd,Asia/Seoul}.log</fileNamePattern>
|
||||
<maxHistory>90</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{HH:mm:ss.SSS,Asia/Seoul} %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="FILE"/>
|
||||
</root>
|
||||
<logger name="com.zaxxer.hikari" level="DEBUG">
|
||||
<appender-ref ref="HIKARI"/>
|
||||
</logger>
|
||||
</configuration>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.ex.chatbot.kakao;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class KakaoApplicationTests {
|
||||
|
||||
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.ex.chatbot.kakao.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class AgentPropertiesTest {
|
||||
|
||||
@Test
|
||||
void isAgentMode_trueOnlyForAgentValue() {
|
||||
AgentProperties props = new AgentProperties();
|
||||
props.setMode("legacy");
|
||||
assertThat(props.isAgentMode()).isFalse();
|
||||
|
||||
props.setMode("agent");
|
||||
assertThat(props.isAgentMode()).isTrue();
|
||||
|
||||
props.setMode("AGENT");
|
||||
assertThat(props.isAgentMode()).isTrue();
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.ex.chatbot.kakao.response.build;
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.View.BasicCardView;
|
||||
import com.ex.chatbot.kakao.response.element.template.Template;
|
||||
import com.ex.chatbot.kakao.response.element.type.buttons.WebLinkButton;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class KakaoIntentAppFaqLinkTest {
|
||||
|
||||
@Test
|
||||
void shouldAppendFaqLinkCardWithDeduplicatedButtons() throws Exception {
|
||||
KakaoIntentApp app = new KakaoIntentApp();
|
||||
List<Template> outputs = new ArrayList<>();
|
||||
String url1 = "https://www.hipass.co.kr/board/FaqDetail.do?ser_no=186";
|
||||
String url2 = "https://www.hipass.co.kr/board/FaqDetail.do?ser_no=192";
|
||||
String url3 = "https://www.hipass.co.kr/board/FaqDetail.do?ser_no=200";
|
||||
String url4 = "https://www.hipass.co.kr/board/FaqDetail.do?ser_no=201";
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("faqUrls", Arrays.asList(url1, "", url1, url2, url3, url4));
|
||||
|
||||
Method method = KakaoIntentApp.class.getDeclaredMethod("appendFaqLinkCard", List.class, Map.class);
|
||||
method.setAccessible(true);
|
||||
method.invoke(app, outputs, response);
|
||||
|
||||
assertThat(outputs).hasSize(1);
|
||||
BasicCardView cardView = (BasicCardView) outputs.get(0);
|
||||
|
||||
assertThat(cardView.getBasicCard().getTitle()).isEqualTo("하이패스 FAQ");
|
||||
assertThat(cardView.getBasicCard().getButtons()).hasSize(3);
|
||||
|
||||
WebLinkButton firstButton = (WebLinkButton) cardView.getBasicCard().getButtons().get(0);
|
||||
WebLinkButton secondButton = (WebLinkButton) cardView.getBasicCard().getButtons().get(1);
|
||||
WebLinkButton thirdButton = (WebLinkButton) cardView.getBasicCard().getButtons().get(2);
|
||||
|
||||
assertThat(firstButton.getLabel()).isEqualTo("FAQ 원문 보기");
|
||||
assertThat(firstButton.getWebLinkUrl()).isEqualTo(url1);
|
||||
assertThat(secondButton.getWebLinkUrl()).isEqualTo(url2);
|
||||
assertThat(thirdButton.getWebLinkUrl()).isEqualTo(url3);
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package com.ex.chatbot.kakao.web;
|
||||
|
||||
import com.ex.chatbot.kakao.response.element.SkillResponse;
|
||||
import com.ex.chatbot.kakao.response.element.template.ListCard;
|
||||
import com.ex.chatbot.kakao.response.element.template.SimpleText;
|
||||
import com.ex.chatbot.kakao.response.element.type.Item;
|
||||
import com.ex.chatbot.kakao.response.element.type.QuickReply;
|
||||
import com.ex.chatbot.kakao.web.dto.WebListCardDto;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class SkillResponseWebExtractorTest {
|
||||
|
||||
private final SkillResponseWebExtractor extractor = new SkillResponseWebExtractor();
|
||||
|
||||
@Test
|
||||
void extract_separatesSimpleTextAndListCards() {
|
||||
List<Item> items = List.of(
|
||||
Item.builder().title("1,965원").description("칠곡(부산)").build(),
|
||||
Item.builder().title("1,954원").description("칠곡(부산)").build()
|
||||
);
|
||||
|
||||
List<com.ex.chatbot.kakao.response.element.template.Template> outputs = new ArrayList<>();
|
||||
outputs.add(SimpleText.getSimpleText("칠곡휴게소 주유 가격을 방향에 따라 안내드립니다."));
|
||||
outputs.add(ListCard.getListCard("칠곡(부산) 주유정보입니다.", null, items, null));
|
||||
|
||||
SkillResponse response = new SkillResponse().getResult(outputs, List.of());
|
||||
SkillResponseWebExtractor.WebExtractResult result = extractor.extract(response);
|
||||
|
||||
assertThat(result.getAnswer()).isEqualTo("칠곡휴게소 주유 가격을 방향에 따라 안내드립니다.");
|
||||
assertThat(result.getListCards()).hasSize(1);
|
||||
assertThat(result.getListCards().get(0).getTitle()).isEqualTo("칠곡(부산) 주유정보입니다.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extract_attachesFareBreakdownToFareCard() {
|
||||
List<Item> items = List.of(
|
||||
Item.builder().title("1종 : 3200원").description("승용차").build()
|
||||
);
|
||||
List<com.ex.chatbot.kakao.response.element.template.Template> outputs = new ArrayList<>();
|
||||
outputs.add(SimpleText.getSimpleText("통행요금 안내입니다."));
|
||||
outputs.add(ListCard.getListCard("판교 - 신갈 통행요금입니다.", null, items, null));
|
||||
|
||||
SkillResponse response = new SkillResponse().getResult(outputs, List.of());
|
||||
Map<String, Object> breakdown = new HashMap<>();
|
||||
breakdown.put("fromIc", "판교");
|
||||
breakdown.put("toIc", "신갈");
|
||||
breakdown.put("totalFare", 3200);
|
||||
response.setData(Map.of("fareBreakdown", breakdown));
|
||||
|
||||
SkillResponseWebExtractor.WebExtractResult result = extractor.extract(response);
|
||||
WebListCardDto card = result.getListCards().get(0);
|
||||
assertThat(card.getFareBreakdown()).isNotNull();
|
||||
assertThat(card.getFareBreakdown().get("fromIc")).isEqualTo("판교");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extract_quickRepliesForWebResend() {
|
||||
List<QuickReply> quickReplies = List.of(
|
||||
QuickReply.getMessageReply("하이패스 충전 방법", "하이패스(선불) 충전은 어떻게 하나요?"),
|
||||
QuickReply.getBlockReply("홈으로", "61ef9177b8ad900c148e68e2")
|
||||
);
|
||||
|
||||
SkillResponse response = new SkillResponse().getResult(
|
||||
List.of(SimpleText.getSimpleText("하이패스 충전 안내입니다.")),
|
||||
quickReplies
|
||||
);
|
||||
|
||||
SkillResponseWebExtractor.WebExtractResult result = extractor.extract(response);
|
||||
assertThat(result.getQuickReplies()).hasSize(1);
|
||||
assertThat(result.getQuickReplies().get(0).getMessage())
|
||||
.isEqualTo("하이패스(선불) 충전은 어떻게 하나요?");
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package com.ex.chatbot.kakao.web;
|
||||
|
||||
import com.ex.chatbot.kakao.web.dto.WebAskRequest;
|
||||
import com.ex.chatbot.kakao.web.dto.WebAskResponse;
|
||||
import com.ex.chatbot.kakao.web.dto.WebListCardDto;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(WebChatController.class)
|
||||
class WebChatControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@MockBean
|
||||
private WebChatService webChatService;
|
||||
|
||||
@Test
|
||||
void ask_returnsWebAskResponse() throws Exception {
|
||||
when(webChatService.ask(any(WebAskRequest.class))).thenReturn(
|
||||
WebAskResponse.builder()
|
||||
.status(true)
|
||||
.answer("판교에서 신갈까지 통행요금 안내입니다.")
|
||||
.botId("web-test")
|
||||
.listCards(List.of(
|
||||
WebListCardDto.builder()
|
||||
.title("판교 - 신갈 통행요금입니다.")
|
||||
.items(List.of())
|
||||
.build()
|
||||
))
|
||||
.build()
|
||||
);
|
||||
|
||||
WebAskRequest request = new WebAskRequest();
|
||||
request.setQuery("판교에서 신갈까지 통행요금");
|
||||
request.setBotId("web-test");
|
||||
|
||||
mockMvc.perform(post("/web/ask")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value(true))
|
||||
.andExpect(jsonPath("$.answer").value("판교에서 신갈까지 통행요금 안내입니다."))
|
||||
.andExpect(jsonPath("$.listCards[0].title").value("판교 - 신갈 통행요금입니다."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiSpec_isAvailable() throws Exception {
|
||||
mockMvc.perform(get("/web/openapi.json"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.openapi").value("3.0.0"))
|
||||
.andExpect(jsonPath("$.paths['/ask']").exists());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user