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:
Macbook
2026-07-21 22:57:30 +09:00
commit 4b86b2a660
344 changed files with 45787 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
# ====================================
# Spring Boot & Gradle
# ====================================
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
# ====================================
# IDE - Eclipse
# ====================================
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
# ====================================
# IDE - IntelliJ IDEA
# ====================================
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
# ====================================
# IDE - NetBeans
# ====================================
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
# ====================================
# IDE - VS Code & Cursor
# ====================================
.vscode/
.cursor/
# ====================================
# OS Files
# ====================================
# Windows
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
# macOS
.DS_Store
.AppleDouble
.LSOverride
._*
# Linux
*~
.directory
# ====================================
# Logs
# ====================================
*.log
logs/
*.log.*
log/
# ====================================
# Environment & Configuration
# ====================================
# 로컬 개발 환경 설정 (민감정보 포함)
application-local.yml
application-dev.yml
application-prod.yml
*-secret.yml
# 환경변수 파일
.env
.env.local
.env.*.local
# ====================================
# Temporary Files
# ====================================
*.tmp
*.temp
*.swp
*.swo
*~.nib
*.bak
*.class
# ====================================
# Gradle
# ====================================
.gradle/
gradle-app.setting
!gradle-wrapper.jar
.gradletasknamecache
# ====================================
# Package Files
# ====================================
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
!gradle/wrapper/gradle-wrapper.jar
# ====================================
# Application Specific
# ====================================
# 업로드된 파일
uploads/
upload/
# 다운로드 파일
downloads/
download/
# 임시 데이터
data/
tmp/
# ====================================
# Documentation (optional)
# ====================================
# 프로젝트 문서는 유지하되, 임시 문서는 제외
**/temp-*.md
**/draft-*.md
# ====================================
# Agent Tools (Cursor specific)
# ====================================
.cursor/projects/*/agent-tools/
@@ -0,0 +1,283 @@
# RAG API 구현 완료 문서
## ✅ 수정 완료된 파일
### 1️⃣ **RagConfig.java** - Docker 스펙에 맞게 수정
```java
- host: localhost (Docker 컨테이너)
- port: 28012 (Docker 외부 포트)
- askEndpoint: /ask
- healthEndpoint: /health (추가)
```
### 2️⃣ **RagServerRequest.java** - API 스펙 적용
```java
{
"query": "사용자 질문" // Docker API 필드명에 맞춤
}
```
### 3️⃣ **RagServerResponse.java** - 실제 응답 구조
```java
{
"answer": "LLM 답변",
"matched_questions": ["질문1", "질문2", "질문3"],
"scores": [0.95, 0.89, 0.82],
"num_references": 5
}
```
### 4️⃣ **RagService.java** - 변환 로직 추가
- question → query 자동 변환
- 헬스체크 기능 추가
- 상세 로깅 추가
### 5️⃣ **RagApiController.java** - 헬스체크 엔드포인트 추가
- POST /api/v1/rag-only (질문 처리)
- GET /api/v1/rag-only/health (헬스체크)
### 6️⃣ **application.yml** - Docker 설정
```yaml
rag:
server:
host: localhost
port: 28012
ask-endpoint: /ask
health-endpoint: /health
```
---
## 🔄 API 처리 흐름
```
챗봇 웹
↓ POST /api/v1/rag-only
↓ {
"question": "회원가입은 어떻게 하나요?",
"botId": "bot-001"
}
↓
[Spring Boot API - RagApiController]
↓
[RagService]
- 입력 검증
- question → query 변환
↓ POST http://localhost:28012/ask
↓ {
"query": "회원가입은 어떻게 하나요?"
}
↓
[Docker 컨테이너 - RAG 서버]
- LLM 처리
- 벡터 검색
- 답변 생성
↓ 응답
↓ {
"answer": "회원가입은 다음과 같이...",
"matched_questions": ["회원가입 방법", "가입 절차", "회원 등록"],
"scores": [0.95, 0.89, 0.82],
"num_references": 5
}
↓
[RagService]
- 응답 가공
↓
[RagApiController]
↓ 최종 응답
↓ {
"status": true,
"statusMsg": "정상응답",
"answer": "회원가입은 다음과 같이...",
"matchedQuestions": ["회원가입 방법", "가입 절차", "회원 등록"],
"scores": [0.95, 0.89, 0.82],
"numReferences": 5
}
↓
챗봇 웹
```
---
## 🧪 테스트 방법
### 1. Docker 컨테이너 직접 테스트
```bash
# 질문 테스트
curl -X POST http://localhost:28012/ask \
-H "Content-Type: application/json" \
-d '{"query":"회원가입은 어떻게 하나요?"}'
# 헬스체크
curl http://localhost:28012/health
```
**예상 응답:**
```json
{
"answer": "회원가입은...",
"matched_questions": ["회원가입 방법", "가입 절차"],
"scores": [0.95, 0.89],
"num_references": 5
}
```
### 2. Spring Boot API 테스트
```bash
# 질문 API
curl -X POST http://localhost:8086/api/v1/rag-only \
-H "Content-Type: application/json" \
-d '{
"question": "회원가입은 어떻게 하나요?",
"botId": "bot-001"
}'
# 헬스체크 API
curl http://localhost:8086/api/v1/rag-only/health
```
**예상 응답 (성공):**
```json
{
"status": true,
"statusMsg": "정상응답",
"answer": "회원가입은 다음과 같이 진행됩니다...",
"matchedQuestions": ["회원가입 방법", "가입 절차", "회원 등록"],
"scores": [0.95, 0.89, 0.82],
"numReferences": 5
}
```
**예상 응답 (실패):**
```json
{
"status": false,
"statusMsg": "RAG 서버 통신 실패"
}
```
### 3. Postman으로 테스트
**질문 API:**
- Method: POST
- URL: `http://localhost:8086/api/v1/rag-only`
- Headers: `Content-Type: application/json`
- Body (raw JSON):
```json
{
"question": "배송은 얼마나 걸리나요?",
"botId": "bot-001"
}
```
**헬스체크 API:**
- Method: GET
- URL: `http://localhost:8086/api/v1/rag-only/health`
---
## 📋 환경별 설정
### 로컬 개발 (application-local.yml)
```yaml
rag:
server:
host: localhost
port: 28012
```
### 개발 서버 (application-dev.yml)
```yaml
rag:
server:
host: 172.16.163.62 # 개발 서버 IP
port: 28012
```
### 운영 서버 (application-prod.yml)
```yaml
rag:
server:
host: ${RAG_SERVER_HOST:localhost}
port: ${RAG_SERVER_PORT:28012}
```
---
## 🔍 로그 확인
### 질문 처리 시
```
INFO - RAG 질문 처리 시작 - 질문: 회원가입은 어떻게 하나요?, 봇ID: bot-001
INFO - RAG 서버 요청 시작 - URL: http://localhost:28012/ask, 질문: 회원가입은 어떻게 하나요?
INFO - RAG 서버 응답 성공 - Status: 200 OK
INFO - RAG 질문 처리 완료 - 참조: 5개, 유사 질문: 3개
```
### 헬스체크 시
```
INFO - RAG 서버 헬스체크 시작
INFO - 헬스체크 URL: http://localhost:28012/health
INFO - RAG 서버 상태: ok, 벡터 개수: 1000
```
### 에러 시
```
ERROR - RAG 서버 통신 실패 - Connection refused
```
---
## ⚠️ 주의사항
1. **Docker 컨테이너 실행 확인**
```bash
docker ps | grep 28012
```
2. **포트 접근 가능 확인**
```bash
telnet localhost 28012
# 또는
nc -zv localhost 28012
```
3. **방화벽 설정 확인**
- 28012 포트가 열려있는지 확인
- 개발 서버에서는 172.16.163.62:28012 접근 가능한지 확인
4. **botId는 선택사항**
- 현재 Docker API에는 전달되지 않음
- 로깅/추적 목적으로만 사용
---
## 📌 다음 단계 (선택사항)
1. **응답 캐싱**
- 동일한 질문에 대한 캐시 처리
- Redis 또는 로컬 캐시 활용
2. **재시도 로직**
- RAG 서버 통신 실패 시 재시도
- Exponential Backoff 적용
3. **모니터링**
- 응답 시간 측정
- 에러율 추적
- Prometheus/Grafana 연동
4. **보안 강화**
- API 키 인증
- Rate Limiting
---
## 🎯 완료!
모든 파일이 Docker RAG 서버 API 스펙에 맞춰 수정되었습니다.
테스트 후 문제가 있으면 알려주세요! 😊
+102
View File
@@ -0,0 +1,102 @@
# chatbotApi 설명서
> 해당 문서는 md(Markdown) 방식으로 작성되었기 때문에 마크다운 뷰어 기능이있는 편집기(vscode 등)에서 보는것을 추천드립니다.(vscode 사용 시 문서 열고 Ctrl + Shift + V)
## 1. 개요
챗봇에서 데이터 조회를 위한 api 서비스(backend, was). intent 정책(SSOT `intent-definitions.yml`)·검증·정규화와 도메인 데이터 조회(Oracle Handler / WEB 위임)를 담당합니다.
### 1.1 시스템 구성 흐름 (WAS 역할)
```mermaid
flowchart TD
RAG[exAiChatBot AgentService] --> TE[AgentToolController /api/v1/tools/*]
POL[intent-definitions.yml + validator/normalizer]
TE --> POL
POL --> OWN{fetchOwner}
OWN -->|WAS| ORA[IntentHandler → Oracle/TROAD]
OWN -->|WEB| SKI[KakaoSkillToolClient → Skill /internal/tools/data-portal]
ORA --> FMT[LlmDomainDataFormatter]
SKI --> FMT
FMT --> OUT([domainData + uiType])
```
| 진입 | 사용 모드 | 설명 |
|------|-----------|------|
| `GET /api/v1/tools/definitions` | Agent | YAML → OpenAI function schema (RAG tool 목록) |
| `POST /api/v1/tools/execute` | Agent | domain tool 실행 (`fetchOwner` 분기: WAS Oracle / WEB Skill 역호출) |
- **정책 SSOT:** `src/main/resources/intent-definitions.yml` (`fetchOwner`/`uiType`/`requiredParams`/`clarification`).
- **핵심 원칙:** LLM은 intent·params만 결정, `fetchOwner`/`uiType`/`needsClarification`은 WAS+YAML+validator가 결정.
- 전체 구조: [`../SYSTEM_ARCHITECTURE.md`](../SYSTEM_ARCHITECTURE.md) · Agent tool API 상세: [`../docs/AGENT_ARCHITECTURE.md`](../docs/AGENT_ARCHITECTURE.md) · RAG API: [`RAG_API_IMPLEMENTATION.md`](RAG_API_IMPLEMENTATION.md)
## 2. 프로그램 관련 정보
서비스 기동 서버 : 172.16.180.130(통합정산개발#1)
* Java version : 8(JDK 1.8)
* 서비스 기동 시점 : 상시
* 연계DB : TROAD(교통정보, Oracle), chatbot(MongoDB)
* 사용 계정 : song
* 서비스 홈 디렉토리 : /home/song/bin/chatbotApi
* 서비스 파일 : chatbotApi-0.0.1-SNAPSHOT.jar
* 서비스 설정 파일(config) : application.yml, logback-spring.xml
* 서비스 로그 파일(logs) : chatbotApi.log
* 서비스 시작 명령어(서비스 홈 디렉토리에서 수행해야한다.)
``` bash
./start.sh start ## 백그라운드(실운영) 기동 명령어, 평시에는 해당 명령어를 사용한다.
./start.sh starta ## 포그라운드 기동 명령어, 에러 탐색 및 모니터링시 활용
```
## 3. 기타 주의 사항
> 해당 서비스에는 교통정보 관련 API만 개발 되어있다. 향후에는 미납 조회 등 모든 서비스를 구현하면 좋을듯. (현재는 DMZ존에 위치한 챗봇 WEB서버(128.200.100.61)에 대부분의 조회 서비스가 운영되고있다. 별로 좋지않은 구조라 생각된다.)
> [ASIS] WEB ↔ DB, [TOBE] WEB ↔ WAS ↔ DB)
## ※ API 일별 사용 건수 조회 Mongo쿼리
```javascript
db.api_use_log.aggregate([
{
$group: {
_id: {
$substr: ["$request_time", 0, 10] // 'yyyy-MM-dd' 부분만 추출
},
count: { $sum: 1 } // 각 날짜별로 문서 수를 계산
}
},
{
$sort: { "_id": 1 } // 날짜별로 정렬
}
]);
```
## ※ API apikey 및 일별 사용 건수 조회 Mongo쿼리
```javascript
db.api_use_log.aggregate([
{
$addFields: {
// 'yyyy-MM-dd' 형식으로 날짜 부분만 추출하여 새 필드에 저장
"dateOnly": {
$substr: ["$request_time", 0, 10] // '2023-11-11' 부분만 추출
}
}
},
{
$group: {
// api_key와 추출된 날짜를 기준으로 그룹화
_id: {
api_key: "$api_key",
date: "$dateOnly"
},
count: { $sum: 1 } // 각 api_key와 날짜별로 문서 수를 계산
}
},
{
$sort: {
"_id.date": 1, // 날짜별로 정렬
"_id.api_key": 1 // api_key별로 정렬
}
}
]);
```
+48
View File
@@ -0,0 +1,48 @@
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.14'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}
group = 'com.ex.'
version = '0.0.1-SNAPSHOT'
java {
// Java 17로 빌드하되, Java 8 바이트코드로 컴파일 (Java 8 환경에서 실행 가능)
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-data-mongodb'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.apache.httpcomponents:httpclient:4.5.13'
implementation 'com.google.code.gson:gson:2.8.9'
implementation 'commons-io:commons-io:2.8.0'
compileOnly 'org.projectlombok:lombok:1.18.30'
runtimeOnly 'com.oracle.database.jdbc:ojdbc8'
annotationProcessor 'org.projectlombok:lombok:1.18.30'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('compileJava') {
options.release = 8
}
tasks.named('test') {
useJUnitPlatform()
}
+15
View File
@@ -0,0 +1,15 @@
# chatbotApi (WAS 172.16.180.130) — 기동 전 export
INTERNAL_TOOL_API_KEY=
# kakaoChatbotSkill 웹 서버 — 실제 IP/호스트로 수정 (기본: 레거시 DMZ 128.200.100.61)
SKILL_INTERNAL_BASE_URL=http://128.200.100.61:8083
# RAG (동일 WAS 서버)
RAG_SERVER_HOST=127.0.0.1
RAG_SERVER_PORT=28012
# MongoDB (동일 서버 로컬)
# MONGODB_URI=mongodb://exDev:ehfhrhdtk!!@127.0.0.1:27017/chatbot?authSource=chatbot
TOOL_EXECUTE_TIMEOUT_MS=30000
@@ -0,0 +1,7 @@
# Gradle이 사용할 Java 버전 설정
# Gradle 데몬은 Java 17 이하를 사용하도록 제한
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# Java Toolchain 자동 다운로드 활성화
org.gradle.java.installations.auto-download=true
Binary file not shown.
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+248
View File
@@ -0,0 +1,248 @@
#!/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/HEAD/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
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# 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
if ! command -v java >/dev/null 2>&1
then
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
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
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
# 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"'
# 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" "$@"
+92
View File
@@ -0,0 +1,92 @@
@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=.
@rem This is normally unused
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,2 @@
config.stopBubbling = true
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier
@@ -0,0 +1 @@
rootProject.name = 'chatbotApi'
@@ -0,0 +1,12 @@
package com.ex.chatbotapi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ChatbotapiApplication {
public static void main(String[] args) {
SpringApplication.run(ChatbotapiApplication.class, args);
}
}
@@ -0,0 +1,22 @@
package com.ex.chatbotapi.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 {
private Tool tool = new Tool();
@Getter
@Setter
public static class Tool {
private String internalApiKey = "";
private int executeTimeoutMs = 30_000;
}
}
@@ -0,0 +1,22 @@
package com.ex.chatbotapi.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 = "kakao-skill")
public class KakaoSkillConfig {
private String baseUrl = "http://localhost:8083";
private String dataPortalToolEndpoint = "/internal/tools/data-portal";
private String internalApiKey = "";
private int connectTimeout = 5000;
private int readTimeout = 30000;
public String getDataPortalToolUrl() {
return baseUrl.replaceAll("/$", "") + dataPortalToolEndpoint;
}
}
@@ -0,0 +1,29 @@
package com.ex.chatbotapi.config;
import com.mongodb.ConnectionString;
import com.mongodb.MongoClientSettings;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.repository.config.EnableMongoRepositories;
@Configuration
@EnableMongoRepositories(basePackages = "com.ex.chatbotapi.domain")
@ConfigurationProperties(prefix = "spring.data.mongodb")
public class MongoDbSettings {
@Value("${spring.data.mongodb.uri}")
private String URI;
@Bean
public MongoClient mongoClient() {
ConnectionString connectionString = new ConnectionString(URI);
MongoClientSettings mongoClientSettings = MongoClientSettings.builder()
.applyConnectionString(connectionString)
.build();
return MongoClients.create(mongoClientSettings);
}
}
@@ -0,0 +1,69 @@
package com.ex.chatbotapi.config;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
@Configuration
public class OracleDbConfig {
@Bean
@ConfigurationProperties("spring.datasource.bpstl")
public DataSourceProperties bpstlDataSourceProperties() {
return new DataSourceProperties();
}
@Bean
@ConfigurationProperties("spring.datasource.troad")
public DataSourceProperties troadDataSourceProperties() {
return new DataSourceProperties();
}
@Bean
@ConfigurationProperties("spring.datasource.restarea")
public DataSourceProperties restAreaDataSourceProperties() {
return new DataSourceProperties();
}
@Bean
@Primary
@Qualifier("bpstlDataSource")
public DataSource bpstlDataSource(){
return bpstlDataSourceProperties()
.initializeDataSourceBuilder()
.build();
}
@Bean
@Qualifier("troadDataSource")
public DataSource troadDataSource(){
return troadDataSourceProperties()
.initializeDataSourceBuilder()
.build();
}
@Bean
@Qualifier("restAreaDataSource")
public DataSource restAreaDataSource(){
return restAreaDataSourceProperties()
.initializeDataSourceBuilder()
.build();
}
@Bean(name = "bpstlTemplate")
public JdbcTemplate bpstlJdbcTemplate(@Qualifier("bpstlDataSource") DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
@Bean(name = "troadTemplate")
public JdbcTemplate troadJdbcTemplate(@Qualifier("troadDataSource") DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
@Bean(name = "restAreaTemplate")
public JdbcTemplate restAreaJdbcTemplate(@Qualifier("restAreaDataSource") DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
}
@@ -0,0 +1,86 @@
package com.ex.chatbotapi.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* RAG 서버 관련 설정 (Docker 컨테이너)
*/
@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "rag.server")
public class RagConfig {
/**
* RAG 서버 호스트 (Docker 컨테이너는 localhost)
*/
private String host = "localhost";
/**
* RAG 서버 포트 (Docker 외부 포트)
*/
private int port = 28012;
/**
* RAG 서버 기본 URL
*/
private String baseUrl;
/**
* 질의 엔드포인트
*/
private String askEndpoint = "/ask";
/**
* 헬스체크 엔드포인트
*/
private String healthEndpoint = "/health";
/**
* LLM 기반 intent 분석 엔드포인트
*/
private String intentAnalyzeEndpoint = "/intent/analyze";
/**
* 연결 타임아웃 (ms)
*/
private int connectTimeout = 5000;
/**
* 읽기 타임아웃 (ms)
*/
private int readTimeout = 30000;
/**
* RAG 서버 질의 URL 반환
*/
public String getFullUrl() {
if (baseUrl != null && !baseUrl.isEmpty()) {
return baseUrl + askEndpoint;
}
return String.format("http://%s:%d%s", host, port, askEndpoint);
}
/**
* RAG 서버 헬스체크 URL 반환
*/
public String getHealthUrl() {
if (baseUrl != null && !baseUrl.isEmpty()) {
return baseUrl + healthEndpoint;
}
return String.format("http://%s:%d%s", host, port, healthEndpoint);
}
/**
* RAG 서버 intent 분석 URL 반환
*/
public String getIntentAnalyzeUrl() {
if (baseUrl != null && !baseUrl.isEmpty()) {
return baseUrl + intentAnalyzeEndpoint;
}
return String.format("http://%s:%d%s", host, port, intentAnalyzeEndpoint);
}
}
@@ -0,0 +1,44 @@
package com.ex.chatbotapi.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;
@Bean
@Primary
public RestTemplate restTemplate() {
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
factory.setReadTimeout(READ_TIMEOUT);
factory.setConnectTimeout(CONNECT_TIMEOUT);
HttpClient httpClient = HttpClientBuilder.create()
.setMaxConnTotal(MAX_CONN_TOTAL)
.setMaxConnPerRoute(MAX_CONN_PER_ROUTE)
.build();
factory.setHttpClient(httpClient);
RestTemplate restTemplate = new RestTemplate(factory);
return restTemplate;
}
}
@@ -0,0 +1,70 @@
package com.ex.chatbotapi.domain.agent.api;
import com.ex.chatbotapi.domain.agent.dto.AgentQueryRequest;
import com.ex.chatbotapi.domain.agent.dto.AgentQueryResponse;
import com.ex.chatbotapi.domain.agent.service.AgentOrchestratorService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 에이전트 통합 API 컨트롤러
*
* KakaoIntentApp 호출 규격:
* POST /api/v1/agent
* {
* "question": "판교에서 신갈까지 요금이 얼마야?",
* "intentType": "FARE_SEARCH",
* "params": { "fromIc": "판교", "toIc": "신갈" },
* "botId": "user_id"
* }
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/agent")
@RequiredArgsConstructor
public class AgentApiController {
private final AgentOrchestratorService agentOrchestratorService;
/**
* 통합 질의 엔드포인트 (KakaoIntentApp → chatbotApi 메인 진입점)
*/
@PostMapping
public ResponseEntity<AgentQueryResponse> processQuery(@RequestBody AgentQueryRequest request) {
log.info("Agent API 호출 - question: {}, intentType: {}",
request.getQuestion(), request.getIntentType());
return ResponseEntity.ok(agentOrchestratorService.processUserQuery(request));
}
/**
* 테스트용 GET 엔드포인트
*/
@GetMapping
public ResponseEntity<AgentQueryResponse> processQueryGet(
@RequestParam String question,
@RequestParam(required = false) String intentType,
@RequestParam(required = false) String carNo,
@RequestParam(required = false) String fromIc,
@RequestParam(required = false) String toIc,
@RequestParam(required = false) String icName,
@RequestParam(required = false) String restAreaName,
@RequestParam(required = false) String botId) {
log.info("Agent API 호출 (GET) - question: {}, intentType: {}", question, intentType);
Map<String, String> params = new HashMap<>();
if (carNo != null) params.put("carNo", carNo);
if (fromIc != null) params.put("fromIc", fromIc);
if (toIc != null) params.put("toIc", toIc);
if (icName != null) params.put("icName", icName);
if (restAreaName != null) params.put("restAreaName", restAreaName);
AgentQueryRequest request = new AgentQueryRequest(question, intentType, params, botId, null);
return ResponseEntity.ok(agentOrchestratorService.processUserQuery(request));
}
}
@@ -0,0 +1,72 @@
package com.ex.chatbotapi.domain.agent.api;
import com.ex.chatbotapi.config.AgentProperties;
import com.ex.chatbotapi.domain.agent.dto.ToolExecuteRequest;
import com.ex.chatbotapi.domain.agent.dto.ToolExecuteResponse;
import com.ex.chatbotapi.domain.agent.service.ToolDefinitionService;
import com.ex.chatbotapi.domain.agent.service.ToolExecutionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/v1/tools")
@RequiredArgsConstructor
public class AgentToolController {
private final ToolDefinitionService toolDefinitionService;
private final ToolExecutionService toolExecutionService;
private final AgentProperties agentProperties;
@GetMapping("/definitions")
public ResponseEntity<?> definitions(
@RequestHeader(value = "X-Internal-Tool-Key", required = false) String apiKey
) {
ResponseEntity<?> authError = authorize(apiKey);
if (authError != null) {
return authError;
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("tools", toolDefinitionService.listToolDefinitions());
return ResponseEntity.ok(body);
}
@PostMapping("/execute")
public ResponseEntity<?> execute(
@RequestHeader(value = "X-Internal-Tool-Key", required = false) String apiKey,
@RequestBody ToolExecuteRequest request
) {
ResponseEntity<?> authError = authorize(apiKey);
if (authError != null) {
return authError;
}
log.info("Tool execute - tool: {}, botId: {}", request.getToolName(), request.getBotId());
return ResponseEntity.ok(toolExecutionService.execute(request));
}
private ResponseEntity<?> authorize(String apiKey) {
String expected = agentProperties.getTool().getInternalApiKey();
if (expected == null || expected.trim().isEmpty()) {
return null;
}
if (expected.equals(apiKey)) {
return null;
}
Map<String, Object> errorBody = new LinkedHashMap<>();
errorBody.put("status", false);
errorBody.put("statusMsg", "invalid or missing X-Internal-Tool-Key");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorBody);
}
}
@@ -0,0 +1,68 @@
package com.ex.chatbotapi.domain.agent.api;
import com.ex.chatbotapi.domain.agent.dto.DomainOnlyRequest;
import com.ex.chatbotapi.domain.agent.dto.DomainOnlyResponse;
import com.ex.chatbotapi.domain.agent.service.DomainOnlyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 도메인 데이터 전용 API 컨트롤러 (LLM/RAG 호출 없음)
*
* LLM 응답이 불필요한 인텐트(예: 전화번호 조회)에서 사용.
* DB 도메인 데이터만 조회하여 반환하므로 응답이 빠르고 비용이 없음.
*
* KakaoIntentApp 호출 규격:
* POST /api/v1/domain-only
* {
* "intentType": "IC_TEL",
* "params": { "icName": "서울" }
* }
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/domain-only")
@RequiredArgsConstructor
public class DomainOnlyApiController {
private final DomainOnlyService domainOnlyService;
/**
* 도메인 데이터 전용 조회 엔드포인트
*/
@PostMapping
public ResponseEntity<DomainOnlyResponse> fetchDomainData(@RequestBody DomainOnlyRequest request) {
log.info("DomainOnly API 호출 - intentType: {}", request.getIntentType());
return ResponseEntity.ok(domainOnlyService.fetchDomainData(request));
}
/**
* 테스트용 GET 엔드포인트
*/
@GetMapping
public ResponseEntity<DomainOnlyResponse> fetchDomainDataGet(
@RequestParam String intentType,
@RequestParam(required = false) String icName,
@RequestParam(required = false) String carNo,
@RequestParam(required = false) String fromIc,
@RequestParam(required = false) String toIc,
@RequestParam(required = false) String restAreaName) {
log.info("DomainOnly API 호출 (GET) - intentType: {}", intentType);
Map<String, String> params = new HashMap<>();
if (icName != null) params.put("icName", icName);
if (carNo != null) params.put("carNo", carNo);
if (fromIc != null) params.put("fromIc", fromIc);
if (toIc != null) params.put("toIc", toIc);
if (restAreaName != null) params.put("restAreaName", restAreaName);
DomainOnlyRequest request = new DomainOnlyRequest(intentType, params);
return ResponseEntity.ok(domainOnlyService.fetchDomainData(request));
}
}
@@ -0,0 +1,26 @@
package com.ex.chatbotapi.domain.agent.api;
import com.ex.chatbotapi.domain.agent.dto.LlmAgentRequest;
import com.ex.chatbotapi.domain.agent.dto.LlmAgentResponse;
import com.ex.chatbotapi.domain.agent.service.LlmAgentRouterService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequestMapping("/api/v1/llm-agent")
@RequiredArgsConstructor
public class LlmAgentController {
private final LlmAgentRouterService llmAgentRouterService;
@PostMapping
public ResponseEntity<LlmAgentResponse> process(@RequestBody LlmAgentRequest request) {
log.info("LLM Agent API 호출 - question: {}, botId: {}", request.getQuestion(), request.getBotId());
return ResponseEntity.ok(llmAgentRouterService.process(request));
}
}
@@ -0,0 +1,65 @@
package com.ex.chatbotapi.domain.agent.client;
import com.ex.chatbotapi.config.KakaoSkillConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Component
@RequiredArgsConstructor
public class KakaoSkillToolClient {
private final RestTemplate restTemplate;
private final KakaoSkillConfig kakaoSkillConfig;
public Map<String, Object> fetchDataPortal(
String intentType,
Map<String, String> params,
String userInput
) {
String url = kakaoSkillConfig.getDataPortalToolUrl();
Map<String, Object> body = new HashMap<>();
body.put("intentType", intentType);
body.put("params", params);
body.put("userInput", userInput);
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
if (kakaoSkillConfig.getInternalApiKey() != null
&& !kakaoSkillConfig.getInternalApiKey().trim().isEmpty()) {
headers.set("X-Internal-Tool-Key", kakaoSkillConfig.getInternalApiKey());
}
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
url,
HttpMethod.POST,
entity,
new ParameterizedTypeReference<Map<String, Object>>() {}
);
Map<String, Object> result = response.getBody();
return result != null ? result : error("empty response");
} catch (RestClientException e) {
log.error("kakao data portal tool failed - intentType: {}, error: {}", intentType, e.getMessage(), e);
return error(e.getMessage());
}
}
private Map<String, Object> error(String message) {
Map<String, Object> result = new HashMap<>();
result.put("status", false);
result.put("statusMsg", message);
return result;
}
}
@@ -0,0 +1,47 @@
package com.ex.chatbotapi.domain.agent.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* KakaoIntentApp → chatbotApi 통합 엔드포인트 요청 DTO
*
* KakaoIntentApp은 @KakaoIntent 라우팅에서 이미 의도를 알고 있으므로
* intentType과 슬롯 파라미터를 직접 담아서 보냄
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AgentQueryRequest {
/** 사용자 원본 질문 */
private String question;
/**
* 의도 타입 (KakaoIntentApp의 @KakaoIntent 블록명 기반)
* 예: "FARE_SEARCH", "REST_AREA_FOOD_LIST", "IC_TEL"
* null 또는 등록된 핸들러 없음 → RAG 단독 처리
*/
private String intentType;
/**
* 슬롯 파라미터 (카카오 슬롯 필링 결과)
* 예: { "fromIc": "판교", "toIc": "신갈" }
* { "restAreaName": "화성" }
* { "carNo": "12가3456" }
*/
private Map<String, String> params;
/** 봇 ID (대화 이력 관리용) */
private String botId;
/**
* 클라이언트(skill)가 직접 수집한 도메인 데이터.
* 값이 있으면 chatbotApi의 IntentHandler 호출을 생략하고 formatter + RAG만 실행한다.
* (WAS에서 접근 불가한 외부 API를 Web 계층에서 호출하는 경우 사용)
*/
private Map<String, Object> domainData;
}
@@ -0,0 +1,47 @@
package com.ex.chatbotapi.domain.agent.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
import java.util.List;
/**
* chatbotApi → KakaoIntentApp 통합 엔드포인트 응답 DTO
*
* KakaoIntentApp은 두 가지를 활용:
* - llmAnswer: SimpleText로 표시 (LLM 자연어 응답)
* - domainData: 기존 ListCard/BasicCard 포맷 구성에 활용 (구조화 DB 데이터)
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AgentQueryResponse {
/** 처리 성공 여부 */
private boolean status;
/** 처리된 의도 타입 */
private String intentType;
/** LLM이 생성한 자연어 응답 (SimpleText 표시용) */
private String llmAnswer;
/**
* DB 조회 구조화 데이터 (카드 포맷 구성용)
* intentType이 없거나 RAG 단독 처리 시 null
*/
private Map<String, Object> domainData;
/** 공식 FAQ 상세 URL 목록 */
private List<String> faqUrls;
/** RAG 참고자료 메타데이터 */
private List<Map<String, Object>> references;
/** 상태 메시지 (에러 시 오류 내용) */
private String statusMsg;
}
@@ -0,0 +1,21 @@
package com.ex.chatbotapi.domain.agent.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.HashMap;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CarNoParamNormalizationResult {
private boolean valid;
private String clarificationQuestion;
@Builder.Default
private Map<String, String> normalizedParams = new HashMap<>();
}
@@ -0,0 +1,31 @@
package com.ex.chatbotapi.domain.agent.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* KakaoIntentApp → chatbotApi 도메인 데이터 전용 엔드포인트 요청 DTO
*
* LLM/RAG 호출 없이 DB 도메인 데이터 조회만 수행.
* 예: getTelIcInfo (전화번호 조회) 등 LLM 응답이 불필요한 경우 사용.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DomainOnlyRequest {
/**
* 의도 타입 (등록된 IntentHandler와 매핑)
* 예: "IC_TEL"
*/
private String intentType;
/**
* 슬롯 파라미터 (카카오 슬롯 필링 결과)
* 예: { "icName": "서울" }
*/
private Map<String, String> params;
}
@@ -0,0 +1,36 @@
package com.ex.chatbotapi.domain.agent.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* chatbotApi → KakaoIntentApp 도메인 데이터 전용 엔드포인트 응답 DTO
*
* llmAnswer 없이 domainData만 반환.
* KakaoIntentApp은 domainData를 직접 파싱해 카드 UI를 구성.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DomainOnlyResponse {
/** 처리 성공 여부 */
private boolean status;
/** 처리된 의도 타입 */
private String intentType;
/**
* DB 조회 구조화 데이터 (카드 포맷 구성용)
* 핸들러 없거나 조회 실패 시 null
*/
private Map<String, Object> domainData;
/** 상태 메시지 (에러 시 오류 내용) */
private String statusMsg;
}
@@ -0,0 +1,15 @@
package com.ex.chatbotapi.domain.agent.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class IcNameCandidate {
private String icName;
private String icCode;
}
@@ -0,0 +1,29 @@
package com.ex.chatbotapi.domain.agent.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class IcParamNormalizationResult {
private boolean ambiguous;
private String ambiguousParam;
private String originalValue;
private String normalizedValue;
private String clarificationQuestion;
@Builder.Default
private Map<String, String> normalizedParams = new HashMap<>();
@Builder.Default
private List<IcNameCandidate> candidates = new ArrayList<>();
}
@@ -0,0 +1,24 @@
package com.ex.chatbotapi.domain.agent.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class IntentValidationResult {
private boolean valid;
@Builder.Default
private Map<String, String> cleanedParams = new HashMap<>();
@Builder.Default
private List<String> missingParams = new ArrayList<>();
private String clarificationQuestion;
}
@@ -0,0 +1,13 @@
package com.ex.chatbotapi.domain.agent.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LlmAgentRequest {
private String question;
private String botId;
}
@@ -0,0 +1,33 @@
package com.ex.chatbotapi.domain.agent.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LlmAgentResponse {
private boolean status;
private String statusMsg;
private String routeType;
private String intentType;
private String fetchOwner;
private String uiType;
private String answer;
private String llmAnswer;
private Map<String, Object> domainData;
private List<String> faqUrls;
private List<Map<String, Object>> references;
private boolean needsClarification;
private String clarificationQuestion;
private String pendingIntentType;
private List<String> missingParams;
private Map<String, String> params;
private List<IcNameCandidate> icCandidates;
}
@@ -0,0 +1,26 @@
package com.ex.chatbotapi.domain.agent.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PendingIntentState {
private String botId;
private String intentType;
@Builder.Default
private Map<String, String> collectedParams = new HashMap<>();
@Builder.Default
private List<String> missingParams = new ArrayList<>();
private Instant createdAt;
}
@@ -0,0 +1,24 @@
package com.ex.chatbotapi.domain.agent.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.HashMap;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RestAreaParamNormalizationResult {
private boolean ambiguous;
private String ambiguousParam;
private String originalValue;
private String normalizedValue;
private String clarificationQuestion;
@Builder.Default
private Map<String, String> normalizedParams = new HashMap<>();
}
@@ -0,0 +1,21 @@
package com.ex.chatbotapi.domain.agent.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.HashMap;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ToolExecuteRequest {
private String toolName;
@Builder.Default
private Map<String, Object> arguments = new HashMap<>();
private String botId;
private String userInput;
}
@@ -0,0 +1,32 @@
package com.ex.chatbotapi.domain.agent.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ToolExecuteResponse {
private boolean status;
private String statusMsg;
private String intentType;
private String fetchOwner;
private String uiType;
@Builder.Default
private Map<String, String> params = new HashMap<>();
private Map<String, Object> domainData;
private boolean needsClarification;
private String clarificationQuestion;
@Builder.Default
private List<String> missingParams = new ArrayList<>();
@Builder.Default
private List<IcNameCandidate> icCandidates = new ArrayList<>();
}
@@ -0,0 +1,58 @@
package com.ex.chatbotapi.domain.agent.dto;
import lombok.Builder;
import lombok.Data;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Data
@Builder
public class ToolParamPipelineResult {
private boolean valid;
@Builder.Default
private Map<String, String> params = new HashMap<>();
private boolean needsClarification;
private String clarificationQuestion;
@Builder.Default
private List<String> missingParams = new ArrayList<>();
@Builder.Default
private List<IcNameCandidate> icCandidates = new ArrayList<>();
public static ToolParamPipelineResult success(Map<String, String> params) {
return ToolParamPipelineResult.builder()
.valid(true)
.params(params)
.build();
}
public static ToolParamPipelineResult clarify(
Map<String, String> params,
String question,
List<String> missingParams
) {
return ToolParamPipelineResult.builder()
.valid(false)
.params(params)
.needsClarification(true)
.clarificationQuestion(question)
.missingParams(missingParams != null ? missingParams : new ArrayList<>())
.build();
}
public static ToolParamPipelineResult icAmbiguous(
Map<String, String> params,
String question,
List<IcNameCandidate> candidates
) {
return ToolParamPipelineResult.builder()
.valid(false)
.params(params)
.needsClarification(true)
.clarificationQuestion(question)
.icCandidates(candidates != null ? candidates : new ArrayList<>())
.build();
}
}
@@ -0,0 +1,20 @@
package com.ex.chatbotapi.domain.agent.formatter;
import java.util.Map;
/**
* RAG/LLM 프롬프트용으로 domainData에 사람이 읽기 쉬운 요약(llmSummary)을 붙인다.
* KakaoIntentApp은 기존 키 구조(fareData 등)를 그대로 쓰므로, 이 인터페이스는 Map을 mutate 하되
* 스킬이 쓰는 필드는 제거하지 않는다.
*/
public interface LlmDomainDataFormatter {
String getIntentType();
/**
* domainData에 "llmSummary" 키(한국어 요약 문자열)를 추가한다.
*
* @param domainData {@link com.ex.chatbotapi.domain.agent.handler.IntentHandler} 조회 결과 (null이면 무시)
*/
void enrich(Map<String, Object> domainData);
}
@@ -0,0 +1,29 @@
package com.ex.chatbotapi.domain.agent.handler;
import java.util.Map;
/**
* 의도(Intent)별 도메인 데이터 조회를 담당하는 핸들러 인터페이스
*
* 새 스킬 추가 시:
* 1. 이 인터페이스를 구현하는 XxxHandler 클래스 생성 (@Component)
* 2. getIntentType()에 intentType 문자열 반환
* 3. fetchDomainData()에 도메인 서비스 호출 로직 작성
* → AgentOrchestratorService 수정 불필요
*/
public interface IntentHandler {
/**
* 처리할 의도 타입 반환 (KakaoIntentApp이 전달하는 intentType과 일치해야 함)
* 예: "FARE_SEARCH", "REST_AREA_PRICE"
*/
String getIntentType();
/**
* 슬롯 파라미터를 받아 도메인 서비스에서 DB 데이터 조회
*
* @param params KakaoIntentApp 슬롯에서 추출한 파라미터 Map
* @return DB 조회 결과 (RAG 프롬프트에 포함되어 LLM에 전달됨)
*/
Map<String, Object> fetchDomainData(Map<String, String> params);
}
@@ -0,0 +1,7 @@
package com.ex.chatbotapi.domain.agent.intent;
public enum FetchOwner {
WAS,
WEB,
NONE
}
@@ -0,0 +1,53 @@
package com.ex.chatbotapi.domain.agent.intent;
import lombok.Data;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@Data
public class IntentDefinition {
private String intentType;
private String description;
private FetchOwner fetchOwner = FetchOwner.WAS;
private String uiType;
private List<String> requiredParams = Collections.emptyList();
private List<String> optionalParams = Collections.emptyList();
private List<ParamDefinition> requiredParamDefinitions = Collections.emptyList();
private List<ParamDefinition> optionalParamDefinitions = Collections.emptyList();
private Double minConfidence;
private Clarification clarification = new Clarification();
public double minConfidenceOrDefault(double defaultValue) {
return minConfidence != null ? minConfidence : defaultValue;
}
public String clarificationFor(List<String> missingParams) {
if (clarification == null) {
return "조회에 필요한 정보를 조금 더 알려주세요.";
}
if (missingParams != null && missingParams.size() == 1 && clarification.getMissing() != null) {
String message = clarification.getMissing().get(missingParams.get(0));
if (message != null && !message.trim().isEmpty()) {
return message;
}
}
return clarification.getDefaultMessage() != null
? clarification.getDefaultMessage()
: "조회에 필요한 정보를 조금 더 알려주세요.";
}
@Data
public static class Clarification {
private String defaultMessage;
private Map<String, String> missing = Collections.emptyMap();
}
@Data
public static class ParamDefinition {
private String name;
private String description;
private List<String> examples = Collections.emptyList();
}
}
@@ -0,0 +1,199 @@
package com.ex.chatbotapi.domain.agent.intent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import org.yaml.snakeyaml.Yaml;
import javax.annotation.PostConstruct;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Component
public class IntentDefinitionRegistry {
private static final String INTENT_DEFINITIONS_FILE = "intent-definitions.yml";
private static final Path EXTERNAL_INTENT_DEFINITIONS_PATH = Paths.get("config", INTENT_DEFINITIONS_FILE);
private final Map<String, IntentDefinition> definitions = new LinkedHashMap<>();
@PostConstruct
@SuppressWarnings("unchecked")
public void load() {
try (InputStream inputStream = openIntentDefinitions()) {
Map<String, Object> root = new Yaml().load(inputStream);
Map<String, Object> intents = (Map<String, Object>) root.get("intents");
definitions.clear();
for (Map.Entry<String, Object> entry : intents.entrySet()) {
String intentType = entry.getKey();
Map<String, Object> raw = (Map<String, Object>) entry.getValue();
IntentDefinition definition = toDefinition(intentType, raw);
definitions.put(intentType, definition);
}
log.info("intent definitions loaded: {}", definitions.keySet());
} catch (Exception e) {
throw new IllegalStateException("intent-definitions.yml 로드 실패", e);
}
}
private InputStream openIntentDefinitions() throws IOException {
if (Files.exists(EXTERNAL_INTENT_DEFINITIONS_PATH)) {
log.info("intent definitions loaded from external file: {}", EXTERNAL_INTENT_DEFINITIONS_PATH.toAbsolutePath());
return Files.newInputStream(EXTERNAL_INTENT_DEFINITIONS_PATH);
}
log.info("external intent definitions not found. using classpath resource: {}", INTENT_DEFINITIONS_FILE);
return new ClassPathResource(INTENT_DEFINITIONS_FILE).getInputStream();
}
public boolean supports(String intentType) {
return intentType != null && definitions.containsKey(intentType);
}
public IntentDefinition get(String intentType) {
return definitions.get(intentType);
}
public Map<String, IntentDefinition> all() {
return Collections.unmodifiableMap(definitions);
}
public List<Map<String, Object>> toAnalysisDefinitions() {
List<Map<String, Object>> result = new ArrayList<>();
for (IntentDefinition definition : definitions.values()) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("intentType", definition.getIntentType());
item.put("description", definition.getDescription());
item.put("params", mergeParamDefinitions(definition));
result.add(item);
}
return result;
}
private List<Map<String, Object>> mergeParamDefinitions(IntentDefinition definition) {
List<Map<String, Object>> params = new ArrayList<>();
appendParamDefinitions(params, definition.getRequiredParamDefinitions(), true);
appendParamDefinitions(params, definition.getOptionalParamDefinitions(), false);
return params;
}
private void appendParamDefinitions(
List<Map<String, Object>> params,
List<IntentDefinition.ParamDefinition> definitions,
boolean required
) {
for (IntentDefinition.ParamDefinition definition : definitions) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("name", definition.getName());
item.put("description", definition.getDescription());
item.put("required", required);
item.put("examples", definition.getExamples());
params.add(item);
}
}
private IntentDefinition toDefinition(String intentType, Map<String, Object> raw) {
IntentDefinition definition = new IntentDefinition();
definition.setIntentType(intentType);
definition.setDescription(asString(raw.get("description")));
definition.setFetchOwner(FetchOwner.valueOf(asString(raw.get("fetchOwner"))));
definition.setUiType(asString(raw.get("uiType")));
List<IntentDefinition.ParamDefinition> requiredParamDefinitions = asParamDefinitions(raw.get("requiredParams"));
List<IntentDefinition.ParamDefinition> optionalParamDefinitions = asParamDefinitions(raw.get("optionalParams"));
definition.setRequiredParamDefinitions(requiredParamDefinitions);
definition.setOptionalParamDefinitions(optionalParamDefinitions);
definition.setRequiredParams(toParamNames(requiredParamDefinitions));
definition.setOptionalParams(toParamNames(optionalParamDefinitions));
definition.setMinConfidence(asDouble(raw.get("minConfidence")));
definition.setClarification(toClarification(raw.get("clarification")));
return definition;
}
@SuppressWarnings("unchecked")
private IntentDefinition.Clarification toClarification(Object value) {
IntentDefinition.Clarification clarification = new IntentDefinition.Clarification();
if (!(value instanceof Map)) {
return clarification;
}
Map<String, Object> raw = (Map<String, Object>) value;
clarification.setDefaultMessage(asString(raw.get("default")));
Map<String, String> missing = new LinkedHashMap<>();
Object missingObject = raw.get("missing");
if (missingObject instanceof Map) {
Map<String, Object> rawMissing = (Map<String, Object>) missingObject;
for (Map.Entry<String, Object> entry : rawMissing.entrySet()) {
missing.put(entry.getKey(), asString(entry.getValue()));
}
}
clarification.setMissing(missing);
return clarification;
}
private String asString(Object value) {
return value != null ? String.valueOf(value) : null;
}
private Double asDouble(Object value) {
if (value instanceof Number) {
return ((Number) value).doubleValue();
}
if (value == null) {
return null;
}
return Double.valueOf(String.valueOf(value));
}
@SuppressWarnings("unchecked")
private List<IntentDefinition.ParamDefinition> asParamDefinitions(Object value) {
if (!(value instanceof List)) {
return Collections.emptyList();
}
List<IntentDefinition.ParamDefinition> result = new ArrayList<>();
for (Object item : (List<Object>) value) {
IntentDefinition.ParamDefinition paramDefinition = new IntentDefinition.ParamDefinition();
if (item instanceof Map) {
Map<String, Object> raw = (Map<String, Object>) item;
paramDefinition.setName(asString(raw.get("name")));
paramDefinition.setDescription(asString(raw.get("description")));
paramDefinition.setExamples(asStringList(raw.get("examples")));
} else {
paramDefinition.setName(asString(item));
}
if (paramDefinition.getName() != null && !paramDefinition.getName().trim().isEmpty()) {
result.add(paramDefinition);
}
}
return result;
}
private List<String> toParamNames(List<IntentDefinition.ParamDefinition> paramDefinitions) {
List<String> result = new ArrayList<>();
for (IntentDefinition.ParamDefinition paramDefinition : paramDefinitions) {
result.add(paramDefinition.getName());
}
return result;
}
@SuppressWarnings("unchecked")
private List<String> asStringList(Object value) {
if (!(value instanceof List)) {
return Collections.emptyList();
}
List<String> result = new ArrayList<>();
for (Object item : (List<Object>) value) {
result.add(asString(item));
}
return result;
}
}
@@ -0,0 +1,131 @@
package com.ex.chatbotapi.domain.agent.service;
import com.ex.chatbotapi.domain.agent.dto.AgentQueryRequest;
import com.ex.chatbotapi.domain.agent.dto.AgentQueryResponse;
import com.ex.chatbotapi.domain.agent.formatter.LlmDomainDataFormatter;
import com.ex.chatbotapi.domain.agent.handler.IntentHandler;
import com.ex.chatbotapi.domain.rag.dto.RagQueryRequest;
import com.ex.chatbotapi.domain.rag.service.RagService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 에이전트 오케스트레이터
*
* 처리 흐름:
* 1. intentType으로 등록된 IntentHandler 조회
* 2. 핸들러가 있으면 DB 도메인 데이터 조회
* 3. 질문 + 도메인 데이터를 RAG 서버(exAiChatBot)로 전달
* 4. LLM 최종 응답 + 구조화 도메인 데이터를 함께 반환
*
* 새 스킬 추가 방법:
* IntentHandler 인터페이스 구현체를 @Component로 등록하기만 하면 됨
* 이 클래스 수정 불필요
*
* LLM용 요약(llmSummary) 추가:
* LlmDomainDataFormatter 구현체를 @Component로 등록 (fare 등 도메인별)
*/
@Slf4j
@Service
public class AgentOrchestratorService {
private final RagService ragService;
private final Map<String, IntentHandler> handlerMap;
private final Map<String, LlmDomainDataFormatter> llmFormatterMap;
public AgentOrchestratorService(List<IntentHandler> handlers,
List<LlmDomainDataFormatter> llmFormatters,
RagService ragService) {
this.ragService = ragService;
this.handlerMap = handlers.stream()
.collect(Collectors.toMap(IntentHandler::getIntentType, Function.identity()));
this.llmFormatterMap = llmFormatters.stream()
.collect(Collectors.toMap(LlmDomainDataFormatter::getIntentType, Function.identity()));
log.info("등록된 IntentHandler ({}개): {}", handlerMap.size(), handlerMap.keySet());
log.info("등록된 LlmDomainDataFormatter ({}개): {}", llmFormatterMap.size(), llmFormatterMap.keySet());
}
/**
* 사용자 질문 처리 메인 메서드
*/
@SuppressWarnings("unchecked")
public AgentQueryResponse processUserQuery(AgentQueryRequest request) {
log.info("Agent Orchestrator - 질문: '{}', intentType: {}",
request.getQuestion(), request.getIntentType());
try {
Map<String, Object> domainData = fetchDomainData(request);
enrichDomainDataForLlm(request.getIntentType(), domainData);
RagQueryRequest ragRequest = new RagQueryRequest();
ragRequest.setQuestion(request.getQuestion());
ragRequest.setBotId(request.getBotId());
ragRequest.setDomainData(domainData);
ragRequest.setIntentType(request.getIntentType());
Map<String, Object> ragResult = ragService.processQuestion(ragRequest);
return AgentQueryResponse.builder()
.status(true)
.intentType(request.getIntentType())
.llmAnswer((String) ragResult.get("answer"))
.domainData(domainData)
.faqUrls((List<String>) ragResult.get("faqUrls"))
.references((List<Map<String, Object>>) ragResult.get("references"))
.statusMsg("정상응답")
.build();
} catch (Exception e) {
log.error("Agent Orchestrator 처리 오류: {}", e.getMessage(), e);
return AgentQueryResponse.builder()
.status(false)
.statusMsg("처리 중 오류 발생: " + e.getMessage())
.build();
}
}
/**
* intentType에 매핑된 핸들러로 DB 데이터 조회
* 등록된 핸들러가 없으면 null 반환 → RAG 단독 처리
*/
private Map<String, Object> fetchDomainData(AgentQueryRequest request) {
// 클라이언트(skill)가 이미 domainData를 수집해서 전달한 경우 handler 생략
if (request.getDomainData() != null && !request.getDomainData().isEmpty()) {
log.info("domainData pre-populated by web (intentType: {}), handler 생략", request.getIntentType());
return request.getDomainData();
}
String intentType = request.getIntentType();
if (intentType == null || request.getParams() == null) {
log.debug("intentType 또는 params 없음 → RAG 단독 처리");
return null;
}
IntentHandler handler = handlerMap.get(intentType);
if (handler == null) {
log.debug("등록된 핸들러 없음 (intentType: {}) → RAG 단독 처리", intentType);
return null;
}
log.info("도메인 데이터 조회 시작 - intentType: {}, params: {}", intentType, request.getParams());
return handler.fetchDomainData(request.getParams());
}
/**
* intentType에 등록된 포매터가 있으면 domainData에 llmSummary 등을 추가 (RAG 프롬프트 가독성).
*/
private void enrichDomainDataForLlm(String intentType, Map<String, Object> domainData) {
if (domainData == null || intentType == null) {
return;
}
LlmDomainDataFormatter formatter = llmFormatterMap.get(intentType);
if (formatter != null) {
formatter.enrich(domainData);
}
}
}
@@ -0,0 +1,75 @@
package com.ex.chatbotapi.domain.agent.service;
import com.ex.chatbotapi.domain.agent.dto.CarNoParamNormalizationResult;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Component
public class CarNoParamNormalizer {
private static final List<String> CAR_NO_INTENTS = Arrays.asList(
"FARE_UNPAID",
"FARE_REFUND"
);
public CarNoParamNormalizationResult normalize(String intentType, Map<String, String> params) {
Map<String, String> normalizedParams = new HashMap<>();
if (params != null) {
normalizedParams.putAll(params);
}
if (!CAR_NO_INTENTS.contains(intentType) || params == null || params.isEmpty()) {
return CarNoParamNormalizationResult.builder()
.valid(true)
.normalizedParams(normalizedParams)
.build();
}
String carNo = normalizedParams.get("carNo");
if (carNo == null || carNo.trim().isEmpty()) {
return invalid(intentType, normalizedParams);
}
String normalizedCarNo = normalizeCarNo(carNo);
if (!isFullCarNo(normalizedCarNo)) {
return invalid(intentType, normalizedParams);
}
normalizedParams.put("carNo", normalizedCarNo);
return CarNoParamNormalizationResult.builder()
.valid(true)
.normalizedParams(normalizedParams)
.build();
}
private String normalizeCarNo(String carNo) {
return carNo.trim()
.replaceAll("[\\s\\-]", "")
.toUpperCase();
}
private boolean isFullCarNo(String carNo) {
return carNo.matches("\\d{2,3}[가-힣]\\d{4}");
}
private CarNoParamNormalizationResult invalid(String intentType, Map<String, String> params) {
return CarNoParamNormalizationResult.builder()
.valid(false)
.normalizedParams(params)
.clarificationQuestion(clarificationQuestion(intentType))
.build();
}
private String clarificationQuestion(String intentType) {
if ("FARE_UNPAID".equals(intentType)) {
return "챗봇에서 미납 통행료 조회가 가능합니다. 조회하려면 전체 차량번호를 입력해 주세요. 예: 12가3456 미납 조회";
}
if ("FARE_REFUND".equals(intentType)) {
return "챗봇에서 환불 내역 조회가 가능합니다. 조회하려면 전체 차량번호를 입력해 주세요. 예: 12가3456 환불 조회";
}
return "조회하려면 전체 차량번호를 입력해 주세요. 예: 12가3456";
}
}
@@ -0,0 +1,77 @@
package com.ex.chatbotapi.domain.agent.service;
import com.ex.chatbotapi.domain.agent.dto.DomainOnlyRequest;
import com.ex.chatbotapi.domain.agent.dto.DomainOnlyResponse;
import com.ex.chatbotapi.domain.agent.handler.IntentHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 도메인 데이터 전용 서비스 (RAG/LLM 호출 없음)
*
* 전화번호 조회(IC_TEL) 등 LLM 응답이 불필요한 인텐트에 사용.
* AgentOrchestratorService와 동일한 IntentHandler 레지스트리를 재사용.
*/
@Slf4j
@Service
public class DomainOnlyService {
private final Map<String, IntentHandler> handlerMap;
public DomainOnlyService(List<IntentHandler> handlers) {
this.handlerMap = handlers.stream()
.collect(Collectors.toMap(IntentHandler::getIntentType, Function.identity()));
log.info("DomainOnlyService 초기화 - 등록된 핸들러 ({}개): {}", handlerMap.size(), handlerMap.keySet());
}
/**
* 도메인 데이터만 조회 (LLM 없음)
*/
public DomainOnlyResponse fetchDomainData(DomainOnlyRequest request) {
String intentType = request.getIntentType();
log.info("DomainOnly 요청 - intentType: {}, params: {}", intentType, request.getParams());
try {
if (intentType == null || request.getParams() == null) {
log.warn("intentType 또는 params 누락");
return DomainOnlyResponse.builder()
.status(false)
.statusMsg("intentType과 params는 필수입니다.")
.build();
}
IntentHandler handler = handlerMap.get(intentType);
if (handler == null) {
log.warn("등록된 핸들러 없음 - intentType: {}", intentType);
return DomainOnlyResponse.builder()
.status(false)
.intentType(intentType)
.statusMsg("처리 가능한 핸들러가 없습니다: " + intentType)
.build();
}
Map<String, Object> domainData = handler.fetchDomainData(request.getParams());
log.info("DomainOnly 조회 완료 - intentType: {}", intentType);
return DomainOnlyResponse.builder()
.status(true)
.intentType(intentType)
.domainData(domainData)
.statusMsg("정상응답")
.build();
} catch (Exception e) {
log.error("DomainOnly 처리 오류 - intentType: {}, 오류: {}", intentType, e.getMessage(), e);
return DomainOnlyResponse.builder()
.status(false)
.intentType(intentType)
.statusMsg("처리 중 오류 발생: " + e.getMessage())
.build();
}
}
}
@@ -0,0 +1,149 @@
package com.ex.chatbotapi.domain.agent.service;
import com.ex.chatbotapi.domain.agent.dto.IcNameCandidate;
import com.ex.chatbotapi.domain.agent.dto.IcParamNormalizationResult;
import com.ex.chatbotapi.domain.fare.repository.FareRepository;
import lombok.RequiredArgsConstructor;
import org.bson.Document;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class IcParamNormalizer {
private static final int CANDIDATE_LIMIT = 5;
private static final List<String> IC_INTENTS = Arrays.asList(
"FARE_SEARCH",
"TROAD_SEARCH_ROUTE",
"IC_TEL"
);
private final FareRepository fareRepository;
public IcParamNormalizationResult normalize(String intentType, Map<String, String> params) {
Map<String, String> normalizedParams = new HashMap<>();
if (params != null) {
normalizedParams.putAll(params);
}
if (!IC_INTENTS.contains(intentType) || params == null || params.isEmpty()) {
return IcParamNormalizationResult.builder()
.normalizedParams(normalizedParams)
.build();
}
for (String paramName : icParamNames(intentType)) {
String originalValue = normalizedParams.get(paramName);
if (originalValue == null || originalValue.trim().isEmpty()) {
continue;
}
String normalizedValue = normalizeInput(originalValue);
Document exact = fareRepository.findIcInfoByName(normalizedValue);
if (exact != null) {
String icName = exact.getString("ic_name");
String icCode = formatIcCode(exact.get("ic_code"));
normalizedParams.put(paramName, icName);
normalizedParams.put(paramName + "Code", icCode);
continue;
}
List<IcNameCandidate> candidates = findCandidates(normalizedValue);
if (!candidates.isEmpty()) {
return IcParamNormalizationResult.builder()
.ambiguous(true)
.ambiguousParam(paramName)
.originalValue(originalValue)
.normalizedValue(normalizedValue)
.normalizedParams(normalizedParams)
.candidates(candidates)
.clarificationQuestion(buildClarificationQuestion(paramName, normalizedValue, candidates))
.build();
}
normalizedParams.put(paramName, normalizedValue);
}
return IcParamNormalizationResult.builder()
.normalizedParams(normalizedParams)
.build();
}
private List<String> icParamNames(String intentType) {
if ("IC_TEL".equals(intentType)) {
return Arrays.asList("icName");
}
return Arrays.asList("fromIc", "toIc");
}
private String normalizeInput(String value) {
String normalized = value.trim().replaceAll("\\s+", "");
String previous;
do {
previous = normalized;
normalized = normalized
.replaceAll("(?i)IC$", "")
.replaceAll("(영업소|톨게이트|요금소)$", "");
} while (!previous.equals(normalized));
return normalized.trim();
}
private List<IcNameCandidate> findCandidates(String icName) {
List<Document> documents = fareRepository.findIcInfoByNameContains(icName, CANDIDATE_LIMIT);
Map<String, IcNameCandidate> unique = new LinkedHashMap<>();
for (Document document : documents) {
String name = document.getString("ic_name");
if (name == null || name.isEmpty()) {
continue;
}
unique.putIfAbsent(name, IcNameCandidate.builder()
.icName(name)
.icCode(formatIcCode(document.get("ic_code")))
.build());
}
return new ArrayList<>(unique.values());
}
private String buildClarificationQuestion(
String paramName,
String normalizedValue,
List<IcNameCandidate> candidates
) {
StringBuilder sb = new StringBuilder();
sb.append(normalizedValue).append("와 정확히 일치하는 영업소를 찾지 못했습니다. ");
sb.append(displayParamName(paramName)).append(" 영업소를 아래 후보 중에서 선택해 주세요.\n");
for (IcNameCandidate candidate : candidates) {
sb.append("- ").append(candidate.getIcName()).append("\n");
}
return sb.toString().trim();
}
private String displayParamName(String paramName) {
if ("fromIc".equals(paramName)) {
return "출발";
}
if ("toIc".equals(paramName)) {
return "도착";
}
return "조회할";
}
private String formatIcCode(Object icCode) {
if (icCode == null) {
return null;
}
String code = String.valueOf(icCode).trim();
if (code.matches("\\d{1,2}")) {
return String.format("%03d", Integer.parseInt(code));
}
return code;
}
}
@@ -0,0 +1,51 @@
package com.ex.chatbotapi.domain.agent.service;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
/**
* LLM tool arguments(Object/List 등)를 validator가 처리 가능한 Map&lt;String, String&gt;으로 변환한다.
*/
@Component
public class IntentAnalysisParamNormalizer {
public Map<String, String> toStringMap(Map<String, ?> rawParams) {
Map<String, String> normalized = new HashMap<>();
if (rawParams == null) {
return normalized;
}
for (Map.Entry<String, ?> entry : rawParams.entrySet()) {
String value = stringify(entry.getValue());
if (value != null && !value.trim().isEmpty()) {
normalized.put(entry.getKey(), value);
}
}
return normalized;
}
private String stringify(Object value) {
if (value == null) {
return null;
}
if (value instanceof Collection) {
return ((Collection<?>) value).stream()
.map(String::valueOf)
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.joining(","));
}
String text = String.valueOf(value).trim();
if (text.startsWith("[") && text.endsWith("]")) {
text = text.substring(1, text.length() - 1)
.replace("\"", "")
.replace("'", "")
.trim();
}
return text.isEmpty() || "null".equalsIgnoreCase(text) ? null : text;
}
}
@@ -0,0 +1,90 @@
package com.ex.chatbotapi.domain.agent.service;
import com.ex.chatbotapi.domain.agent.dto.IntentValidationResult;
import com.ex.chatbotapi.domain.agent.intent.IntentDefinition;
import com.ex.chatbotapi.domain.agent.intent.IntentDefinitionRegistry;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Component
@RequiredArgsConstructor
public class IntentParamValidator {
private static final Set<String> INVALID_PLACEHOLDERS = new HashSet<>(Arrays.asList(
"여기", "거기", "저기", "현재위치", "현재 위치", "내위치", "내 위치", "이곳", "그곳", "저곳"
));
private final IntentDefinitionRegistry intentDefinitionRegistry;
public boolean supports(String intentType) {
return intentDefinitionRegistry.supports(intentType);
}
public IntentValidationResult validate(String intentType, Map<String, String> params) {
Map<String, String> cleaned = cleanParams(params);
List<String> missing = new ArrayList<>();
IntentDefinition definition = intentDefinitionRegistry.get(intentType);
if (definition == null) {
return IntentValidationResult.builder()
.valid(false)
.cleanedParams(cleaned)
.missingParams(Collections.<String>emptyList())
.clarificationQuestion("처리할 수 있는 업무 유형인지 확인이 필요합니다.")
.build();
}
for (String key : definition.getRequiredParams()) {
if (!hasUsableValue(cleaned.get(key))) {
missing.add(key);
}
}
return IntentValidationResult.builder()
.valid(missing.isEmpty())
.cleanedParams(cleaned)
.missingParams(missing)
.clarificationQuestion(definition.clarificationFor(missing))
.build();
}
public Map<String, String> cleanParams(Map<String, String> params) {
Map<String, String> cleaned = new HashMap<>();
if (params == null) {
return cleaned;
}
for (Map.Entry<String, String> entry : params.entrySet()) {
String value = normalize(entry.getValue());
if (hasUsableValue(value)) {
cleaned.put(entry.getKey(), value);
}
}
return cleaned;
}
private boolean hasUsableValue(String value) {
if (value == null || value.trim().isEmpty()) {
return false;
}
return !INVALID_PLACEHOLDERS.contains(normalize(value));
}
private String normalize(String value) {
if (value == null) {
return null;
}
return value.trim()
.replaceAll("(에서|부터|까지|으로|로|은|는|이|가|을|를)$", "")
.trim();
}
}
@@ -0,0 +1,473 @@
package com.ex.chatbotapi.domain.agent.service;
import com.ex.chatbotapi.domain.agent.dto.AgentQueryRequest;
import com.ex.chatbotapi.domain.agent.dto.AgentQueryResponse;
import com.ex.chatbotapi.domain.agent.dto.CarNoParamNormalizationResult;
import com.ex.chatbotapi.domain.agent.dto.IcParamNormalizationResult;
import com.ex.chatbotapi.domain.agent.dto.IntentValidationResult;
import com.ex.chatbotapi.domain.agent.dto.LlmAgentRequest;
import com.ex.chatbotapi.domain.agent.dto.LlmAgentResponse;
import com.ex.chatbotapi.domain.agent.dto.PendingIntentState;
import com.ex.chatbotapi.domain.agent.dto.RestAreaParamNormalizationResult;
import com.ex.chatbotapi.domain.agent.intent.FetchOwner;
import com.ex.chatbotapi.domain.agent.intent.IntentDefinition;
import com.ex.chatbotapi.domain.agent.intent.IntentDefinitionRegistry;
import com.ex.chatbotapi.domain.rag.client.RagServerClient;
import com.ex.chatbotapi.domain.rag.dto.IntentAnalysisRequest;
import com.ex.chatbotapi.domain.rag.dto.IntentAnalysisResponse;
import com.ex.chatbotapi.domain.rag.dto.RagQueryRequest;
import com.ex.chatbotapi.domain.rag.service.RagService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
@Slf4j
@Service
@RequiredArgsConstructor
public class LlmAgentRouterService {
private static final double DOMAIN_CONFIDENCE_THRESHOLD = 0.60;
private static final String FARE_UNPAID_INTENT = "FARE_UNPAID";
private static final Pattern FULL_CAR_NO_PATTERN = Pattern.compile(".*\\d{2,3}\\s*[가-힣]\\s*\\d{4}.*");
private final RagServerClient ragServerClient;
private final RagService ragService;
private final AgentOrchestratorService agentOrchestratorService;
private final IntentParamValidator intentParamValidator;
private final CarNoParamNormalizer carNoParamNormalizer;
private final IcParamNormalizer icParamNormalizer;
private final RestAreaParamNormalizer restAreaParamNormalizer;
private final PendingIntentService pendingIntentService;
private final IntentDefinitionRegistry intentDefinitionRegistry;
public LlmAgentResponse process(LlmAgentRequest request) {
validateRequest(request);
long startedAt = System.currentTimeMillis();
PendingIntentState pending = pendingIntentService.get(request.getBotId());
IntentAnalysisResponse analysis = analyze(request, pending);
LlmAgentResponse response;
if (pending != null) {
LlmAgentResponse pendingResponse = tryResolvePending(request, pending, analysis);
if (pendingResponse != null) {
response = pendingResponse;
logRouteDecision(request, analysis, response, startedAt);
return response;
}
}
response = processFreshIntent(request, analysis);
logRouteDecision(request, analysis, response, startedAt);
return response;
}
private IntentAnalysisResponse analyze(LlmAgentRequest request, PendingIntentState pending) {
IntentAnalysisRequest analysisRequest = IntentAnalysisRequest.builder()
.query(request.getQuestion())
.botId(request.getBotId())
.pendingIntentType(pending != null ? pending.getIntentType() : null)
.pendingParams(pending != null ? pending.getCollectedParams() : null)
.intentDefinitions(intentDefinitionRegistry.toAnalysisDefinitions())
.build();
IntentAnalysisResponse response = ragServerClient.analyzeIntent(analysisRequest);
if (response == null) {
response = new IntentAnalysisResponse();
}
if (response.getParams() == null) {
response.setParams(new HashMap<String, String>());
}
log.info("LLM intent 분석 결과 - intentType: {}, confidence: {}, params: {}, reason: {}",
response.getIntentType(), response.getConfidence(), response.getParams(), response.getReason());
return response;
}
private LlmAgentResponse tryResolvePending(
LlmAgentRequest request,
PendingIntentState pending,
IntentAnalysisResponse analysis
) {
boolean sameIntent = pending.getIntentType().equals(analysis.getIntentType());
boolean simpleSlotAnswer = isSimpleSlotAnswer(request.getQuestion(), pending);
if (!sameIntent && intentParamValidator.supports(analysis.getIntentType())
&& analysis.confidenceOrZero() >= DOMAIN_CONFIDENCE_THRESHOLD) {
log.info("새 intent 감지로 기존 pending 취소 - old: {}, new: {}",
pending.getIntentType(), analysis.getIntentType());
pendingIntentService.clear(request.getBotId());
return processFreshIntent(request, analysis);
}
if (!sameIntent && !simpleSlotAnswer) {
log.info("pending과 무관한 질문으로 판단, RAG로 전환 - pending: {}", pending.getIntentType());
pendingIntentService.clear(request.getBotId());
return processRagOnly(request);
}
if (isUnpaidGuidanceQuestion(request.getQuestion()) && !containsFullCarNo(request.getQuestion())) {
log.info("미납 안내성 질문으로 판단, pending 취소 후 RAG로 전환 - pending: {}", pending.getIntentType());
pendingIntentService.clear(request.getBotId());
return processRagOnly(request);
}
Map<String, String> merged = new HashMap<>(pending.getCollectedParams());
merged.putAll(intentParamValidator.cleanParams(analysis.getParams()));
if (simpleSlotAnswer && pending.getMissingParams() != null && pending.getMissingParams().size() == 1) {
merged.put(pending.getMissingParams().get(0), request.getQuestion().trim());
}
IntentValidationResult validation = intentParamValidator.validate(pending.getIntentType(), merged);
if (validation.isValid()) {
CarNoParamNormalizationResult carNoNormalization = carNoParamNormalizer.normalize(
pending.getIntentType(),
validation.getCleanedParams());
if (!carNoNormalization.isValid()) {
savePendingForParam(request.getBotId(), pending.getIntentType(), carNoNormalization.getNormalizedParams(), "carNo");
return paramClarificationResponse(
pending.getIntentType(),
carNoNormalization.getNormalizedParams(),
"carNo",
carNoNormalization.getClarificationQuestion());
}
IcParamNormalizationResult normalization = icParamNormalizer.normalize(
pending.getIntentType(),
carNoNormalization.getNormalizedParams());
if (normalization.isAmbiguous()) {
saveIcCandidatePending(request.getBotId(), pending.getIntentType(), normalization);
return icCandidateClarificationResponse(pending.getIntentType(), normalization);
}
RestAreaParamNormalizationResult restAreaNormalization = restAreaParamNormalizer.normalize(
pending.getIntentType(),
normalization.getNormalizedParams());
pendingIntentService.clear(request.getBotId());
return processByFetchOwner(
request,
pending.getIntentType(),
restAreaNormalization.getNormalizedParams(),
"pending_filled");
}
savePending(request.getBotId(), pending.getIntentType(), validation);
return clarificationResponse(pending.getIntentType(), validation);
}
private LlmAgentResponse processFreshIntent(LlmAgentRequest request, IntentAnalysisResponse analysis) {
String intentType = analysis.getIntentType();
IntentDefinition definition = intentDefinitionRegistry.get(intentType);
if (definition != null
&& analysis.confidenceOrZero() >= definition.minConfidenceOrDefault(DOMAIN_CONFIDENCE_THRESHOLD)) {
if (FARE_UNPAID_INTENT.equals(intentType)
&& isUnpaidGuidanceQuestion(request.getQuestion())
&& !containsFullCarNo(request.getQuestion())) {
pendingIntentService.clear(request.getBotId());
log.info("미납 안내성 질문으로 판단, 차량번호 보완질문 대신 RAG로 전환 - question: {}", request.getQuestion());
return processRagOnly(request);
}
IntentValidationResult validation = intentParamValidator.validate(intentType, analysis.getParams());
if (validation.isValid()) {
CarNoParamNormalizationResult carNoNormalization = carNoParamNormalizer.normalize(
intentType,
validation.getCleanedParams());
if (!carNoNormalization.isValid()) {
savePendingForParam(request.getBotId(), intentType, carNoNormalization.getNormalizedParams(), "carNo");
return paramClarificationResponse(
intentType,
carNoNormalization.getNormalizedParams(),
"carNo",
carNoNormalization.getClarificationQuestion());
}
IcParamNormalizationResult normalization = icParamNormalizer.normalize(
intentType,
carNoNormalization.getNormalizedParams());
if (normalization.isAmbiguous()) {
saveIcCandidatePending(request.getBotId(), intentType, normalization);
return icCandidateClarificationResponse(intentType, normalization);
}
RestAreaParamNormalizationResult restAreaNormalization = restAreaParamNormalizer.normalize(
intentType,
normalization.getNormalizedParams());
pendingIntentService.clear(request.getBotId());
return processByFetchOwner(request, intentType, restAreaNormalization.getNormalizedParams(), "domain");
}
savePending(request.getBotId(), intentType, validation);
return clarificationResponse(intentType, validation);
}
pendingIntentService.clear(request.getBotId());
return processRagOnly(request);
}
private LlmAgentResponse processByFetchOwner(
LlmAgentRequest request,
String intentType,
Map<String, String> params,
String routeType
) {
IntentDefinition definition = intentDefinitionRegistry.get(intentType);
if (definition == null) {
return processRagOnly(request);
}
if (definition.getFetchOwner() == FetchOwner.WEB) {
return webFetchRequiredResponse(intentType, params, definition);
}
if (definition.getFetchOwner() == FetchOwner.NONE) {
return processRagOnly(request);
}
return processDomain(request, intentType, params, routeType, definition);
}
private LlmAgentResponse processDomain(
LlmAgentRequest request,
String intentType,
Map<String, String> params,
String routeType,
IntentDefinition definition
) {
AgentQueryRequest agentRequest = new AgentQueryRequest(
request.getQuestion(),
intentType,
params,
request.getBotId(),
null
);
AgentQueryResponse agentResponse = agentOrchestratorService.processUserQuery(agentRequest);
String answer = agentResponse.getLlmAnswer() != null
? agentResponse.getLlmAnswer()
: agentResponse.getStatusMsg();
return LlmAgentResponse.builder()
.status(agentResponse.isStatus())
.statusMsg(agentResponse.getStatusMsg())
.routeType(routeType)
.intentType(intentType)
.fetchOwner(definition.getFetchOwner().name())
.uiType(definition.getUiType())
.answer(answer)
.llmAnswer(agentResponse.getLlmAnswer())
.domainData(agentResponse.getDomainData())
.faqUrls(agentResponse.getFaqUrls())
.references(agentResponse.getReferences())
.needsClarification(false)
.params(params)
.build();
}
private LlmAgentResponse webFetchRequiredResponse(
String intentType,
Map<String, String> params,
IntentDefinition definition
) {
return LlmAgentResponse.builder()
.status(true)
.statusMsg("클라이언트 도메인 데이터 조회 필요")
.routeType("web_fetch_required")
.intentType(intentType)
.fetchOwner(definition.getFetchOwner().name())
.uiType(definition.getUiType())
.needsClarification(false)
.params(params)
.build();
}
@SuppressWarnings("unchecked")
private LlmAgentResponse processRagOnly(LlmAgentRequest request) {
RagQueryRequest ragRequest = new RagQueryRequest(request.getQuestion(), request.getBotId(), null, null);
Map<String, Object> ragResult = ragService.processQuestion(ragRequest);
String answer = String.valueOf(ragResult.get("answer"));
return LlmAgentResponse.builder()
.status(Boolean.TRUE.equals(ragResult.get("status")))
.statusMsg((String) ragResult.get("statusMsg"))
.routeType("rag")
.fetchOwner(FetchOwner.NONE.name())
.answer(answer)
.llmAnswer(answer)
.faqUrls((List<String>) ragResult.get("faqUrls"))
.references((List<Map<String, Object>>) ragResult.get("references"))
.needsClarification(false)
.build();
}
private LlmAgentResponse clarificationResponse(String intentType, IntentValidationResult validation) {
String question = validation.getClarificationQuestion();
return LlmAgentResponse.builder()
.status(true)
.statusMsg("필수 파라미터 추가 입력 필요")
.routeType("clarify")
.intentType(intentType)
.fetchOwner(resolveFetchOwner(intentType))
.answer(question)
.llmAnswer(question)
.needsClarification(true)
.clarificationQuestion(question)
.pendingIntentType(intentType)
.missingParams(validation.getMissingParams())
.params(validation.getCleanedParams())
.build();
}
private LlmAgentResponse icCandidateClarificationResponse(
String intentType,
IcParamNormalizationResult normalization
) {
String question = normalization.getClarificationQuestion();
return LlmAgentResponse.builder()
.status(true)
.statusMsg("영업소 후보 선택 필요")
.routeType("clarify")
.intentType(intentType)
.fetchOwner(resolveFetchOwner(intentType))
.answer(question)
.llmAnswer(question)
.needsClarification(true)
.clarificationQuestion(question)
.pendingIntentType(intentType)
.missingParams(Collections.singletonList(normalization.getAmbiguousParam()))
.params(normalization.getNormalizedParams())
.icCandidates(normalization.getCandidates())
.build();
}
private LlmAgentResponse paramClarificationResponse(
String intentType,
Map<String, String> params,
String missingParam,
String question
) {
return LlmAgentResponse.builder()
.status(true)
.statusMsg("파라미터 추가 입력 필요")
.routeType("clarify")
.intentType(intentType)
.fetchOwner(resolveFetchOwner(intentType))
.answer(question)
.llmAnswer(question)
.needsClarification(true)
.clarificationQuestion(question)
.pendingIntentType(intentType)
.missingParams(Collections.singletonList(missingParam))
.params(params)
.build();
}
private String resolveFetchOwner(String intentType) {
IntentDefinition definition = intentDefinitionRegistry.get(intentType);
return definition != null && definition.getFetchOwner() != null
? definition.getFetchOwner().name()
: null;
}
private boolean isUnpaidGuidanceQuestion(String question) {
if (question == null) {
return false;
}
String normalized = question.replaceAll("\\s+", "");
if (!normalized.contains("미납")) {
return false;
}
return normalized.contains("방법")
|| normalized.contains("어떻게")
|| normalized.contains("어디서")
|| normalized.contains("어디")
|| normalized.contains("절차")
|| normalized.contains("경로")
|| normalized.contains("사이트")
|| normalized.contains("홈페이지")
|| normalized.contains("앱");
}
private boolean containsFullCarNo(String question) {
return question != null && FULL_CAR_NO_PATTERN.matcher(question).matches();
}
private void savePending(String botId, String intentType, IntentValidationResult validation) {
PendingIntentState state = PendingIntentState.builder()
.botId(botId)
.intentType(intentType)
.collectedParams(validation.getCleanedParams())
.missingParams(validation.getMissingParams())
.build();
pendingIntentService.save(state);
}
private void saveIcCandidatePending(
String botId,
String intentType,
IcParamNormalizationResult normalization
) {
PendingIntentState state = PendingIntentState.builder()
.botId(botId)
.intentType(intentType)
.collectedParams(normalization.getNormalizedParams())
.missingParams(Collections.singletonList(normalization.getAmbiguousParam()))
.build();
pendingIntentService.save(state);
}
private void savePendingForParam(
String botId,
String intentType,
Map<String, String> params,
String missingParam
) {
PendingIntentState state = PendingIntentState.builder()
.botId(botId)
.intentType(intentType)
.collectedParams(params)
.missingParams(Collections.singletonList(missingParam))
.build();
pendingIntentService.save(state);
}
private boolean isSimpleSlotAnswer(String question, PendingIntentState pending) {
if (question == null || pending.getMissingParams() == null || pending.getMissingParams().size() != 1) {
return false;
}
String text = question.trim();
if (text.length() > 20 || text.contains("?") || text.contains("알려") || text.contains("조회")) {
return false;
}
return !text.contains(" ");
}
private void validateRequest(LlmAgentRequest request) {
if (request == null || request.getQuestion() == null || request.getQuestion().trim().isEmpty()) {
throw new IllegalArgumentException("질문이 비어있습니다.");
}
}
private void logRouteDecision(
LlmAgentRequest request,
IntentAnalysisResponse analysis,
LlmAgentResponse response,
long startedAt
) {
long elapsedMs = System.currentTimeMillis() - startedAt;
log.info("[LLM_ROUTER] botId={}, query='{}', intentType={}, confidence={}, routeType={}, fetchOwner={}, uiType={}, missingParams={}, elapsedMs={}",
request.getBotId(),
request.getQuestion(),
analysis.getIntentType(),
analysis.getConfidence(),
response.getRouteType(),
response.getFetchOwner(),
response.getUiType(),
response.getMissingParams(),
elapsedMs);
}
}
@@ -0,0 +1,56 @@
package com.ex.chatbotapi.domain.agent.service;
import com.ex.chatbotapi.domain.agent.dto.PendingIntentState;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Service
public class PendingIntentService {
private static final Duration TTL = Duration.ofMinutes(5);
private final Map<String, PendingIntentState> states = new ConcurrentHashMap<>();
public PendingIntentState get(String botId) {
if (botId == null || botId.trim().isEmpty()) {
return null;
}
PendingIntentState state = states.get(botId);
if (state == null) {
return null;
}
if (state.getCreatedAt() == null || state.getCreatedAt().plus(TTL).isBefore(Instant.now())) {
states.remove(botId);
log.info("pending intent 만료 - botId: {}, intentType: {}", botId, state.getIntentType());
return null;
}
return state;
}
public void save(PendingIntentState state) {
if (state == null || state.getBotId() == null || state.getBotId().trim().isEmpty()) {
return;
}
state.setCreatedAt(Instant.now());
states.put(state.getBotId(), state);
log.info("pending intent 저장 - botId: {}, intentType: {}, missing: {}",
state.getBotId(), state.getIntentType(), state.getMissingParams());
}
public void clear(String botId) {
if (botId == null || botId.trim().isEmpty()) {
return;
}
PendingIntentState removed = states.remove(botId);
if (removed != null) {
log.info("pending intent 삭제 - botId: {}, intentType: {}", botId, removed.getIntentType());
}
}
}
@@ -0,0 +1,52 @@
package com.ex.chatbotapi.domain.agent.service;
import com.ex.chatbotapi.domain.agent.dto.RestAreaParamNormalizationResult;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Component
public class RestAreaParamNormalizer {
private static final List<String> REST_AREA_INTENTS = Arrays.asList(
"GASSTATION",
"BRAND_SHOP",
"REST_AREA_FOOD_LIST",
"REST_AREA_PRICE"
);
public RestAreaParamNormalizationResult normalize(String intentType, Map<String, String> params) {
Map<String, String> normalizedParams = new HashMap<>();
if (params != null) {
normalizedParams.putAll(params);
}
if (!REST_AREA_INTENTS.contains(intentType) || params == null || params.isEmpty()) {
return RestAreaParamNormalizationResult.builder()
.normalizedParams(normalizedParams)
.build();
}
String restAreaName = normalizedParams.get("restAreaName");
if (restAreaName != null && !restAreaName.trim().isEmpty()) {
String normalizedValue = normalizeRestAreaName(restAreaName);
normalizedParams.put("restAreaName", normalizedValue);
}
return RestAreaParamNormalizationResult.builder()
.normalizedParams(normalizedParams)
.build();
}
private String normalizeRestAreaName(String value) {
String normalized = value.trim().replaceAll("\\s+", "");
String previous;
do {
previous = normalized;
normalized = normalized.replaceAll("(휴게소|하이웨이파크)$", "");
} while (!previous.equals(normalized));
return normalized.trim();
}
}
@@ -0,0 +1,86 @@
package com.ex.chatbotapi.domain.agent.service;
import com.ex.chatbotapi.domain.agent.intent.FetchOwner;
import com.ex.chatbotapi.domain.agent.intent.IntentDefinition;
import com.ex.chatbotapi.domain.agent.intent.IntentDefinitionRegistry;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* intent-definitions.yml → OpenAI function(tool) 스키마 생성.
*/
@Service
@RequiredArgsConstructor
public class ToolDefinitionService {
private final IntentDefinitionRegistry intentDefinitionRegistry;
public List<Map<String, Object>> listToolDefinitions() {
List<Map<String, Object>> tools = new ArrayList<>();
for (IntentDefinition definition : intentDefinitionRegistry.all().values()) {
if (definition.getFetchOwner() == FetchOwner.NONE) {
continue;
}
tools.add(toOpenAiTool(definition));
}
return tools;
}
private Map<String, Object> toOpenAiTool(IntentDefinition definition) {
Map<String, Object> properties = new LinkedHashMap<>();
List<String> required = new ArrayList<>();
appendParamProperties(properties, required, definition.getRequiredParamDefinitions(), true);
appendParamProperties(properties, required, definition.getOptionalParamDefinitions(), false);
Map<String, Object> parameters = new LinkedHashMap<>();
parameters.put("type", "object");
parameters.put("properties", properties);
parameters.put("required", required);
Map<String, Object> function = new LinkedHashMap<>();
function.put("name", definition.getIntentType());
function.put(
"description",
definition.getDescription()
+ " [fetchOwner="
+ definition.getFetchOwner()
+ ", uiType="
+ definition.getUiType()
+ "]"
);
function.put("parameters", parameters);
Map<String, Object> tool = new LinkedHashMap<>();
tool.put("type", "function");
tool.put("function", function);
return tool;
}
private void appendParamProperties(
Map<String, Object> properties,
List<String> required,
List<IntentDefinition.ParamDefinition> params,
boolean markRequired
) {
if (params == null) {
return;
}
for (IntentDefinition.ParamDefinition param : params) {
Map<String, Object> schema = new LinkedHashMap<>();
schema.put("type", "string");
schema.put("description", param.getDescription());
if (param.getExamples() != null && !param.getExamples().isEmpty()) {
schema.put("examples", param.getExamples());
}
properties.put(param.getName(), schema);
if (markRequired) {
required.add(param.getName());
}
}
}
}
@@ -0,0 +1,147 @@
package com.ex.chatbotapi.domain.agent.service;
import com.ex.chatbotapi.domain.agent.client.KakaoSkillToolClient;
import com.ex.chatbotapi.domain.agent.dto.ToolExecuteRequest;
import com.ex.chatbotapi.domain.agent.dto.ToolExecuteResponse;
import com.ex.chatbotapi.domain.agent.dto.ToolParamPipelineResult;
import com.ex.chatbotapi.domain.agent.formatter.LlmDomainDataFormatter;
import com.ex.chatbotapi.domain.agent.handler.IntentHandler;
import com.ex.chatbotapi.domain.agent.intent.FetchOwner;
import com.ex.chatbotapi.domain.agent.intent.IntentDefinition;
import com.ex.chatbotapi.domain.agent.intent.IntentDefinitionRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Slf4j
@Service
public class ToolExecutionService {
private final IntentDefinitionRegistry intentDefinitionRegistry;
private final Map<String, IntentHandler> handlerMap;
private final Map<String, LlmDomainDataFormatter> llmFormatterMap;
private final KakaoSkillToolClient kakaoSkillToolClient;
private final ToolParamPipeline toolParamPipeline;
public ToolExecutionService(
IntentDefinitionRegistry intentDefinitionRegistry,
List<IntentHandler> handlers,
List<LlmDomainDataFormatter> llmFormatters,
KakaoSkillToolClient kakaoSkillToolClient,
ToolParamPipeline toolParamPipeline
) {
this.intentDefinitionRegistry = intentDefinitionRegistry;
this.handlerMap = handlers.stream()
.collect(Collectors.toMap(IntentHandler::getIntentType, Function.identity()));
this.llmFormatterMap = llmFormatters.stream()
.collect(Collectors.toMap(LlmDomainDataFormatter::getIntentType, Function.identity()));
this.kakaoSkillToolClient = kakaoSkillToolClient;
this.toolParamPipeline = toolParamPipeline;
}
public ToolExecuteResponse execute(ToolExecuteRequest request) {
String toolName = request.getToolName();
if (toolName == null || toolName.trim().isEmpty()) {
return ToolExecuteResponse.builder()
.status(false)
.statusMsg("toolName is required")
.build();
}
IntentDefinition definition = intentDefinitionRegistry.get(toolName);
if (definition == null) {
return ToolExecuteResponse.builder()
.status(false)
.statusMsg("unknown tool: " + toolName)
.build();
}
ToolParamPipelineResult pipeline = toolParamPipeline.process(toolName, request.getArguments());
if (!pipeline.isValid()) {
return ToolExecuteResponse.builder()
.status(false)
.statusMsg(pipeline.isNeedsClarification()
? "clarification required"
: "parameter validation failed")
.intentType(toolName)
.fetchOwner(definition.getFetchOwner().name())
.uiType(definition.getUiType())
.params(pipeline.getParams())
.needsClarification(pipeline.isNeedsClarification())
.clarificationQuestion(pipeline.getClarificationQuestion())
.missingParams(pipeline.getMissingParams())
.icCandidates(pipeline.getIcCandidates())
.build();
}
Map<String, String> params = pipeline.getParams();
FetchOwner fetchOwner = definition.getFetchOwner();
Map<String, Object> domainData;
try {
if (fetchOwner == FetchOwner.WAS) {
domainData = fetchWasDomainData(toolName, params);
} else if (fetchOwner == FetchOwner.WEB) {
domainData = kakaoSkillToolClient.fetchDataPortal(
toolName,
params,
request.getUserInput()
);
} else {
return ToolExecuteResponse.builder()
.status(false)
.statusMsg("tool not executable: " + toolName)
.intentType(toolName)
.fetchOwner(fetchOwner.name())
.uiType(definition.getUiType())
.params(params)
.build();
}
} catch (Exception e) {
log.error("tool execution failed - tool: {}, error: {}", toolName, e.getMessage(), e);
return ToolExecuteResponse.builder()
.status(false)
.statusMsg("tool execution failed: " + e.getMessage())
.intentType(toolName)
.fetchOwner(fetchOwner.name())
.uiType(definition.getUiType())
.params(params)
.build();
}
boolean ok = domainData != null && Boolean.TRUE.equals(domainData.get("status"));
String statusMsg = ok
? "정상응답"
: String.valueOf(domainData != null ? domainData.get("statusMsg") : "no data");
return ToolExecuteResponse.builder()
.status(ok)
.statusMsg(statusMsg)
.intentType(toolName)
.fetchOwner(fetchOwner.name())
.uiType(definition.getUiType())
.params(params)
.domainData(domainData)
.build();
}
private Map<String, Object> fetchWasDomainData(String intentType, Map<String, String> params) {
IntentHandler handler = handlerMap.get(intentType);
if (handler == null) {
Map<String, Object> fallback = new HashMap<>();
fallback.put("status", false);
fallback.put("statusMsg", "WAS handler not registered: " + intentType);
return fallback;
}
Map<String, Object> domainData = handler.fetchDomainData(params);
LlmDomainDataFormatter formatter = llmFormatterMap.get(intentType);
if (formatter != null && domainData != null) {
formatter.enrich(domainData);
}
return domainData;
}
}
@@ -0,0 +1,68 @@
package com.ex.chatbotapi.domain.agent.service;
import com.ex.chatbotapi.domain.agent.dto.CarNoParamNormalizationResult;
import com.ex.chatbotapi.domain.agent.dto.IcParamNormalizationResult;
import com.ex.chatbotapi.domain.agent.dto.IntentValidationResult;
import com.ex.chatbotapi.domain.agent.dto.RestAreaParamNormalizationResult;
import com.ex.chatbotapi.domain.agent.dto.ToolParamPipelineResult;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* LlmAgentRouterService와 동일한 파라미터 검증·정규화 체인 (tool execute용).
*/
@Component
@RequiredArgsConstructor
public class ToolParamPipeline {
private final IntentAnalysisParamNormalizer intentAnalysisParamNormalizer;
private final IntentParamValidator intentParamValidator;
private final CarNoParamNormalizer carNoParamNormalizer;
private final IcParamNormalizer icParamNormalizer;
private final RestAreaParamNormalizer restAreaParamNormalizer;
public ToolParamPipelineResult process(String intentType, Map<String, ?> rawArguments) {
Map<String, String> params = intentAnalysisParamNormalizer.toStringMap(rawArguments);
IntentValidationResult validation = intentParamValidator.validate(intentType, params);
if (!validation.isValid()) {
return ToolParamPipelineResult.clarify(
validation.getCleanedParams(),
validation.getClarificationQuestion(),
validation.getMissingParams()
);
}
CarNoParamNormalizationResult carNoResult = carNoParamNormalizer.normalize(
intentType,
validation.getCleanedParams()
);
if (!carNoResult.isValid()) {
return ToolParamPipelineResult.clarify(
carNoResult.getNormalizedParams(),
carNoResult.getClarificationQuestion(),
java.util.Collections.singletonList("carNo")
);
}
IcParamNormalizationResult icResult = icParamNormalizer.normalize(
intentType,
carNoResult.getNormalizedParams()
);
if (icResult.isAmbiguous()) {
return ToolParamPipelineResult.icAmbiguous(
icResult.getNormalizedParams(),
icResult.getClarificationQuestion(),
icResult.getCandidates()
);
}
RestAreaParamNormalizationResult restAreaResult = restAreaParamNormalizer.normalize(
intentType,
icResult.getNormalizedParams()
);
return ToolParamPipelineResult.success(restAreaResult.getNormalizedParams());
}
}
@@ -0,0 +1,58 @@
package com.ex.chatbotapi.domain.fare.api;
import com.ex.chatbotapi.domain.fare.service.FareService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 통행요금 관련 API 컨트롤러
* HTTP 요청 처리만 담당하고, 비즈니스 로직은 FareService에 위임
*/
@Slf4j
@RestController
@RequiredArgsConstructor
public class FareApiController {
private final FareService fareService;
/**
* Description : 환불내역 조회 메소드
*/
@GetMapping("/api/fare/refund")
public ResponseEntity<Map<String, Object>> queryRefund(String carNo) {
log.info("API 호출 : queryRefund(GET) - carNo: {}", carNo);
return ResponseEntity.ok(fareService.getRefundInfo(carNo));
}
/**
* Description : 미납내역 조회 메소드
*/
@GetMapping("/api/fare/unpaid")
public ResponseEntity<Map<String, Object>> queryUnpaid(String carNo) {
log.info("API 호출 : queryUnpaid(GET) - carNo: {}", carNo);
return ResponseEntity.ok(fareService.getUnpaidInfo(carNo));
}
/**
* Description : 영업소 전화번호 조회 메소드
*/
@GetMapping("/api/call/icTel")
public ResponseEntity<Map<String, Object>> queryIcTel(String icName) {
log.info("API 호출 : queryIcTel(GET) - icName: {}", icName);
return ResponseEntity.ok(fareService.getIcTelInfo(icName));
}
/**
* Description : 통행요금 조회 메소드
*/
@GetMapping("/api/fare/searchFare")
public ResponseEntity<Map<String, Object>> querySearchFare(String fromIc, String toIc) {
log.info("API 호출 : querySearchFare(GET) - fromIc: {}, toIc: {}", fromIc, toIc);
return ResponseEntity.ok(fareService.searchFare(fromIc, toIc));
}
}
@@ -0,0 +1,40 @@
package com.ex.chatbotapi.domain.fare.formatter;
import com.ex.chatbotapi.domain.agent.formatter.LlmDomainDataFormatter;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* FARE_REFUND — 환불 가능 통행료 조회 결과 요약.
*/
@Component
public class FareRefundLlmFormatter implements LlmDomainDataFormatter {
@Override
public String getIntentType() {
return "FARE_REFUND";
}
@Override
public void enrich(Map<String, Object> domainData) {
if (domainData == null || domainData.isEmpty()) {
return;
}
Object st = domainData.get("status");
boolean ok = st instanceof Boolean && (Boolean) st;
String carNo = domainData.get("carNo") != null ? String.valueOf(domainData.get("carNo")) : "(차량번호 없음)";
if (!ok) {
domainData.put("llmSummary",
"차량번호 " + carNo + " 에 대해 환불 대상 통행료가 조회되지 않았거나, "
+ "일부 노선에서는 환불 내역 확인이 제한될 수 있습니다.");
return;
}
domainData.put("llmSummary",
"차량번호 " + carNo + " 에 환불 가능한 통행료 정보가 조회되었습니다.\n"
+ "환불 신청 및 상세 내역은 한국도로공사 공식 홈페이지 또는 고객센터(1588-2504)를 이용해 주세요.");
}
}
@@ -0,0 +1,105 @@
package com.ex.chatbotapi.domain.fare.formatter;
import com.ex.chatbotapi.domain.agent.formatter.LlmDomainDataFormatter;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* FARE_SEARCH — 통행요금 조회 결과를 LLM용 요약 문자열로 정리한다.
*/
@Component
public class FareSearchLlmFormatter implements LlmDomainDataFormatter {
@Override
public String getIntentType() {
return "FARE_SEARCH";
}
@Override
public void enrich(Map<String, Object> domainData) {
if (domainData == null || domainData.isEmpty()) {
return;
}
Object st = domainData.get("status");
boolean ok = st instanceof Boolean && (Boolean) st;
if (!ok) {
String msg = domainData.get("statusMsg") != null
? String.valueOf(domainData.get("statusMsg"))
: "통행요금 조회에 실패했습니다.";
domainData.put("llmSummary", msg);
return;
}
@SuppressWarnings("unchecked")
Map<String, Object> fare = (Map<String, Object>) domainData.get("fareData");
if (fare == null || fare.isEmpty()) {
domainData.put("llmSummary", "통행요금 조회는 성공했으나 fareData가 없습니다.");
return;
}
String type = String.valueOf(fare.getOrDefault("type", ""));
StringBuilder sb = new StringBuilder();
switch (type) {
case "exact":
sb.append("구간 통행요금(정액 산정 가능):\n");
sb.append("- 출발: ").append(fare.get("fromIc")).append(", 도착: ").append(fare.get("toIc")).append("\n");
appendVehicleClassLines(sb, fare);
break;
case "open_both":
sb.append("개방식 영업소(출발·도착 모두 개방식으로 판단):\n");
sb.append("- 기준 영업소: ").append(fare.get("icName")).append("\n");
sb.append("- 개방식 요금은 해당 영업소 통과 기준 단일 구간 요금입니다.\n");
appendVehicleClassLines(sb, fare);
break;
case "open_from":
sb.append("개방식 영업소(출발이 개방식):\n");
sb.append("- 출발 영업소: ").append(fare.get("icName")).append(", 목적지 쪽 영업소: ")
.append(fare.get("toIc")).append("\n");
Object outs = fare.get("outIcs");
if (outs instanceof List && !((List<?>) outs).isEmpty()) {
sb.append("- 출구 후보(일부): ").append(outs).append("\n");
}
appendVehicleClassLines(sb, fare);
break;
case "open_to":
sb.append("개방식 영업소(도착이 개방식):\n");
sb.append("- 도착 영업소: ").append(fare.get("icName")).append(", 출발 쪽 영업소: ")
.append(fare.get("fromIc")).append("\n");
Object outs2 = fare.get("outIcs");
if (outs2 instanceof List && !((List<?>) outs2).isEmpty()) {
sb.append("- 출구 후보(일부): ").append(outs2).append("\n");
}
appendVehicleClassLines(sb, fare);
break;
default:
sb.append("통행요금 조회(type=").append(type).append("):\n");
appendVehicleClassLines(sb, fare);
}
domainData.put("llmSummary", sb.toString().trim());
}
private static void appendVehicleClassLines(StringBuilder sb, Map<String, Object> fare) {
sb.append("- 1종: ").append(won(fare.get("fare1")))
.append(" (경차 1종: ").append(won(fare.get("fare1Small"))).append(")\n");
sb.append("- 2종: ").append(won(fare.get("fare2"))).append("\n");
sb.append("- 3종: ").append(won(fare.get("fare3"))).append("\n");
sb.append("- 4종: ").append(won(fare.get("fare4"))).append("\n");
sb.append("- 5종: ").append(won(fare.get("fare5"))).append("\n");
}
private static String won(Object v) {
if (v == null) {
return "-";
}
if (v instanceof Number) {
return ((Number) v).intValue() + "원";
}
return v + "원";
}
}
@@ -0,0 +1,64 @@
package com.ex.chatbotapi.domain.fare.formatter;
import com.ex.chatbotapi.domain.agent.formatter.LlmDomainDataFormatter;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* FARE_UNPAID — 미납 통행료 조회 결과 요약.
*/
@Component
public class FareUnpaidLlmFormatter implements LlmDomainDataFormatter {
@Override
public String getIntentType() {
return "FARE_UNPAID";
}
@Override
public void enrich(Map<String, Object> domainData) {
if (domainData == null || domainData.isEmpty()) {
return;
}
Object st = domainData.get("status");
boolean ok = st instanceof Boolean && (Boolean) st;
String carNo = domainData.get("carNo") != null ? String.valueOf(domainData.get("carNo")) : "(차량번호 없음)";
long sum = toLong(domainData.get("sumAmt"));
if (!ok) {
String message = domainData.get("statusMsg") != null
? String.valueOf(domainData.get("statusMsg"))
: "미납 통행료 조회에 실패했습니다.";
domainData.put("llmSummary", message);
return;
}
if (sum < 100) {
domainData.put("llmSummary",
"차량번호 " + carNo + " 기준 조회된 미납 통행료는 없습니다.\n"
+ "고객에게 미납금액이 없다고 명확히 안내하세요.");
return;
}
domainData.put("llmSummary",
"차량번호 " + carNo + " 기준 미납 통행료가 조회되었습니다.\n"
+ "총 미납금액은 " + sum + "원입니다.\n"
+ "상세 납부는 하이패스/홈페이지 등 공식 채널을 이용해야 합니다.");
}
private static long toLong(Object o) {
if (o == null) {
return 0L;
}
if (o instanceof Number) {
return ((Number) o).longValue();
}
try {
return Long.parseLong(String.valueOf(o));
} catch (NumberFormatException e) {
return 0L;
}
}
}
@@ -0,0 +1,49 @@
package com.ex.chatbotapi.domain.fare.formatter;
import com.ex.chatbotapi.domain.agent.formatter.LlmDomainDataFormatter;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* IC_TEL — 영업소 대표 전화번호 조회 결과 요약.
*/
@Component
public class IcTelLlmFormatter implements LlmDomainDataFormatter {
@Override
public String getIntentType() {
return "IC_TEL";
}
@Override
public void enrich(Map<String, Object> domainData) {
if (domainData == null || domainData.isEmpty()) {
return;
}
Object st = domainData.get("status");
boolean ok = st instanceof Boolean && (Boolean) st;
if (!ok) {
String msg = domainData.get("statusMsg") != null
? String.valueOf(domainData.get("statusMsg"))
: "영업소 연락처를 조회하지 못했습니다.";
domainData.put("llmSummary", msg);
return;
}
String icName = domainData.get("icName") != null ? String.valueOf(domainData.get("icName")) : "";
String tel = domainData.get("tel") != null ? String.valueOf(domainData.get("tel")) : "";
String code = domainData.get("tolofCd") != null ? String.valueOf(domainData.get("tolofCd")) : "";
StringBuilder sb = new StringBuilder();
sb.append("고속도로 영업소 연락처 조회 결과:\n");
sb.append("- 영업소명: ").append(icName).append("\n");
sb.append("- 대표전화: ").append(tel).append("\n");
if (!code.isEmpty()) {
sb.append("- 영업소코드(참고): ").append(code);
}
domainData.put("llmSummary", sb.toString().trim());
}
}
@@ -0,0 +1,25 @@
package com.ex.chatbotapi.domain.fare.handler;
import com.ex.chatbotapi.domain.fare.service.FareService;
import com.ex.chatbotapi.domain.agent.handler.IntentHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class FareRefundHandler implements IntentHandler {
private final FareService fareService;
@Override
public String getIntentType() {
return "FARE_REFUND";
}
@Override
public Map<String, Object> fetchDomainData(Map<String, String> params) {
return fareService.getRefundInfo(params.get("carNo"));
}
}
@@ -0,0 +1,25 @@
package com.ex.chatbotapi.domain.fare.handler;
import com.ex.chatbotapi.domain.fare.service.FareService;
import com.ex.chatbotapi.domain.agent.handler.IntentHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class FareSearchHandler implements IntentHandler {
private final FareService fareService;
@Override
public String getIntentType() {
return "FARE_SEARCH";
}
@Override
public Map<String, Object> fetchDomainData(Map<String, String> params) {
return fareService.searchFare(params.get("fromIc"), params.get("toIc"));
}
}
@@ -0,0 +1,25 @@
package com.ex.chatbotapi.domain.fare.handler;
import com.ex.chatbotapi.domain.fare.service.FareService;
import com.ex.chatbotapi.domain.agent.handler.IntentHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class FareUnpaidHandler implements IntentHandler {
private final FareService fareService;
@Override
public String getIntentType() {
return "FARE_UNPAID";
}
@Override
public Map<String, Object> fetchDomainData(Map<String, String> params) {
return fareService.getUnpaidInfo(params.get("carNo"));
}
}
@@ -0,0 +1,25 @@
package com.ex.chatbotapi.domain.fare.handler;
import com.ex.chatbotapi.domain.fare.service.FareService;
import com.ex.chatbotapi.domain.agent.handler.IntentHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class IcTelHandler implements IntentHandler {
private final FareService fareService;
@Override
public String getIntentType() {
return "IC_TEL";
}
@Override
public Map<String, Object> fetchDomainData(Map<String, String> params) {
return fareService.getIcTelInfo(params.get("icName"));
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,163 @@
package com.ex.chatbotapi.domain.fare.service;
import org.bson.Document;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* MongoDB trs_amt.amt / sum 배열에서 차종별·구간별 요금과 웹용 breakdown을 추출한다.
*/
final class FareAmountHelper {
private static final String[] VEHICLE_LABELS = {"1종", "2종", "3종", "4종", "5종", "경차"};
private static final String[] VEHICLE_DESCRIPTIONS = {
"승용차, 소형승합차/화물차, 경차",
"중형승합차, 중형화물차",
"대형승합차, 2축 대형화물차",
"3축 대형화물차",
"4축 이상 특수 화물차",
"1종(경차)"
};
private FareAmountHelper() {
}
static void applyExactFareAmounts(Map<String, Object> fareData, Document trsDoc) {
List<Integer> totalList = firstNonNullList(
readIntegerList(trsDoc.get("sum")),
readAmtList(trsDoc, "sum")
);
if (totalList == null || totalList.size() < 6) {
return;
}
List<Integer> kecList = firstNonNullList(
readAmtList(trsDoc, "00"),
totalList
);
List<Integer> privateList = subtractLists(totalList, kecList);
putLegacyFareFields(fareData, totalList);
putSectionFareFields(fareData, "kec", kecList);
putSectionFareFields(fareData, "private", privateList);
fareData.put("webBreakdown", buildWebBreakdown(
String.valueOf(fareData.get("fromIc")),
String.valueOf(fareData.get("toIc")),
totalList,
kecList,
privateList
));
}
private static Map<String, Object> buildWebBreakdown(
String fromIc,
String toIc,
List<Integer> totalList,
List<Integer> kecList,
List<Integer> privateList
) {
Map<String, Object> breakdown = new HashMap<>();
breakdown.put("fromIc", fromIc);
breakdown.put("toIc", toIc);
breakdown.put("routeLabel", fromIc + " → " + toIc);
breakdown.put("introText",
"요청하신 경로의 통행요금입니다. 도로공사 구간 요금·민자도로 구간 요금 조회 결과입니다.");
breakdown.put("totalFare", totalList.get(0));
breakdown.put("kecFare", kecList.get(0));
breakdown.put("privateFare", privateList.get(0));
breakdown.put("compactTotalFare", totalList.get(5));
breakdown.put("compactKecFare", kecList.get(5));
breakdown.put("compactPrivateFare", privateList.get(5));
List<Map<String, Object>> vehicles = new ArrayList<>();
for (int i = 0; i < 5; i++) {
vehicles.add(vehicleEntry(i, totalList, kecList, privateList, false));
}
vehicles.add(vehicleEntry(5, totalList, kecList, privateList, true));
breakdown.put("vehicles", vehicles);
return breakdown;
}
private static Map<String, Object> vehicleEntry(
int index,
List<Integer> totalList,
List<Integer> kecList,
List<Integer> privateList,
boolean compact
) {
Map<String, Object> vehicle = new HashMap<>();
vehicle.put("typeLabel", VEHICLE_LABELS[index]);
vehicle.put("description", VEHICLE_DESCRIPTIONS[index]);
vehicle.put("total", totalList.get(index));
vehicle.put("kec", kecList.get(index));
vehicle.put("private", privateList.get(index));
vehicle.put("compact", compact);
return vehicle;
}
private static void putLegacyFareFields(Map<String, Object> fareData, List<Integer> totalList) {
fareData.put("fare1", totalList.get(0));
fareData.put("fare2", totalList.get(1));
fareData.put("fare3", totalList.get(2));
fareData.put("fare4", totalList.get(3));
fareData.put("fare5", totalList.get(4));
fareData.put("fare1Small", totalList.get(5));
}
private static void putSectionFareFields(
Map<String, Object> fareData,
String prefix,
List<Integer> values
) {
fareData.put(prefix + "Fare1", values.get(0));
fareData.put(prefix + "Fare2", values.get(1));
fareData.put(prefix + "Fare3", values.get(2));
fareData.put(prefix + "Fare4", values.get(3));
fareData.put(prefix + "Fare5", values.get(4));
fareData.put(prefix + "Fare1Small", values.get(5));
}
private static List<Integer> readAmtList(Document trsDoc, String key) {
Object amtObj = trsDoc.get("amt");
if (!(amtObj instanceof Document)) {
return null;
}
return readIntegerList(((Document) amtObj).get(key));
}
@SuppressWarnings("unchecked")
private static List<Integer> readIntegerList(Object value) {
if (!(value instanceof List)) {
return null;
}
List<Integer> result = new ArrayList<>();
for (Object item : (List<?>) value) {
if (item instanceof Number) {
result.add(((Number) item).intValue());
}
}
return result.isEmpty() ? null : result;
}
private static List<Integer> subtractLists(List<Integer> totalList, List<Integer> kecList) {
List<Integer> privateList = new ArrayList<>(totalList.size());
for (int i = 0; i < totalList.size(); i++) {
privateList.add(Math.max(0, totalList.get(i) - kecList.get(i)));
}
return privateList;
}
@SafeVarargs
private static List<Integer> firstNonNullList(List<Integer>... lists) {
for (List<Integer> list : lists) {
if (list != null && !list.isEmpty()) {
return list;
}
}
return null;
}
}
@@ -0,0 +1,322 @@
package com.ex.chatbotapi.domain.fare.service;
import com.ex.chatbotapi.domain.fare.repository.FareRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.bson.Document;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 통행요금 관련 비즈니스 로직 처리 서비스
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FareService {
private final FareRepository fareRepository;
/**
* 환불내역 조회
*/
public Map<String, Object> getRefundInfo(String carNo) {
log.info("{} 차량 환불내역 조회 시작", carNo);
Map<String, Object> response = new HashMap<>();
try {
List<Map<String, Object>> list = fareRepository.findRefundByCarNo(carNo);
int refundCnt = list.size();
if (refundCnt == 0) {
log.info("{} 차량 환불내역 존재하지 않음", carNo);
response.put("status", false);
response.put("statusMsg", "환불 내역이 존재하지 않습니다.");
response.put("carNo", carNo);
} else {
log.info("{} 차량 환불내역이 존재합니다.", carNo);
response.put("status", true);
response.put("statusMsg", "환불 내역이 존재합니다.");
response.put("carNo", carNo);
response.put("refundData", list.get(0));
}
return response;
} catch (Exception e) {
log.error("환불내역 조회 에러 : {}", e.getMessage(), e);
response.put("status", false);
response.put("statusMsg", "서비스에 일시적인 장애가 발생하였습니다.");
response.put("errorMsg", e.getMessage());
return response;
}
}
/**
* 미납내역 조회
*/
public Map<String, Object> getUnpaidInfo(String carNo) {
log.info("{} 차량 미납내역 조회 시작", carNo);
Map<String, Object> response = new HashMap<>();
try {
List<Map<String, Object>> list = fareRepository.findUnpaidByCarNo(carNo);
long sumAmt = 0;
for (Map<String, Object> map : list) {
if (map.get("USER_NAME") != null) {
sumAmt += ((java.math.BigDecimal) map.get("UNPAID_AMT")).toBigInteger().longValue();
log.info("미납금액 : {}", map.get("UNPAID_AMT"));
map.remove("COL_WILL_AMT");
map.remove("COL_EXTRA_WILL_AMT");
map.remove("WORK_NO");
map.remove("SER_NO");
map.remove("LANETYPE");
map.remove("OUT_IC_CODE");
}
}
if (sumAmt < 100) {
log.info("{} 차량 미납내역 존재하지 않음", carNo);
response.put("status", true);
response.put("statusMsg", "미납 내역이 존재하지 않습니다.");
response.put("carNo", carNo);
response.put("sumAmt", sumAmt);
response.put("hasUnpaid", false);
} else {
log.info("{} 차량 미납내역이 존재합니다. 총 금액: {}", carNo, sumAmt);
response.put("status", true);
response.put("statusMsg", "미납 내역이 존재합니다.");
response.put("carNo", carNo);
response.put("sumAmt", sumAmt);
response.put("hasUnpaid", true);
response.put("unpaidList", list);
}
return response;
} catch (Exception e) {
log.error("미납내역 조회 에러 : {}", e.getMessage(), e);
response.put("status", false);
response.put("statusMsg", "서비스에 일시적인 장애가 발생하였습니다.");
response.put("errorMsg", e.getMessage());
return response;
}
}
/**
* 영업소 전화번호 조회
*/
public Map<String, Object> getIcTelInfo(String icName) {
log.info("{} 영업소 연락처 조회를 시작합니다", icName);
Map<String, Object> response = new HashMap<>();
try {
if (icName == null || icName.isEmpty()) {
log.warn("영업소 이름이 없습니다");
response.put("status", false);
response.put("statusMsg", "영업소 이름이 없습니다.");
return response;
}
Map<String, Object> icTelData = fareRepository.findIcTelByName(icName);
if (icTelData == null) {
log.info("{} 영업소 정보를 찾을 수 없습니다.", icName);
response.put("status", false);
response.put("statusMsg", icName + " 영업소는 제가 모르는 영업소입니다.");
return response;
}
String tolofCd = (String) icTelData.get("TOLOF_CD");
String korIcName = (String) icTelData.get("KOR_TOLOF_NM");
String tel = (String) icTelData.get("TOLOF_TELNO");
log.info("영업소 정보 조회 성공: 코드={}, 이름={}, 전화번호={}", tolofCd, korIcName, tel);
if (tel == null || tel.equals("")) {
log.warn("{} 영업소는 전화번호 정보가 없습니다.", icName);
response.put("status", false);
response.put("statusMsg", icName + " 영업소는 전화번호 정보가 없습니다.");
return response;
}
log.info("{} 영업소 연락처 조회 성공", icName);
response.put("status", true);
response.put("statusMsg", "정상응답");
response.put("icName", korIcName);
response.put("tel", tel);
response.put("tolofCd", tolofCd);
return response;
} catch (Exception e) {
log.error("영업소 전화번호 조회 에러 : {}", e.getMessage(), e);
response.put("status", false);
response.put("statusMsg", "서비스에 일시적인 장애가 발생하였습니다.");
response.put("errorMsg", e.getMessage());
return response;
}
}
/**
* 통행요금 조회
*/
public Map<String, Object> searchFare(String fromIc, String toIc) {
log.info("{}와 {} 간의 요금을 계산합니다", fromIc, toIc);
Map<String, Object> response = new HashMap<>();
try {
Document fromIcInfo = fareRepository.findIcInfoByName(fromIc);
if (fromIcInfo == null) {
log.warn("{} 영업소를 찾을 수 없습니다", fromIc);
response.put("status", false);
response.put("statusMsg", fromIc + " : 제가 알지 못하는 영업소 입니다.");
return response;
}
Document toIcInfo = fareRepository.findIcInfoByName(toIc);
if (toIcInfo == null) {
log.warn("{} 영업소를 찾을 수 없습니다", toIc);
response.put("status", false);
response.put("statusMsg", toIc + " : 제가 알지 못하는 영업소 입니다.");
return response;
}
String fromCode = fromIcInfo.get("ic_code").toString();
String toCode = toIcInfo.get("ic_code").toString();
Document trsUnique = fareRepository.findFareByIcNames(fromIc, toIc);
if (trsUnique != null && !fromIc.equals("대구") && !toIc.equals("대구")) {
Map<String, Object> fareData = new HashMap<>();
fareData.put("type", "exact");
fareData.put("fromIc", fromIc);
fareData.put("toIc", toIc);
FareAmountHelper.applyExactFareAmounts(fareData, trsUnique);
response.put("status", true);
response.put("statusMsg", "정상응답");
response.put("fareData", fareData);
return response;
}
int fromCodeInt = Integer.parseInt(fromCode);
int toCodeInt = Integer.parseInt(toCode);
Map<String, Object> icTelMap = fareRepository.getIcTelMap();
if (fromCodeInt < 100 && toCodeInt < 100) {
@SuppressWarnings("unchecked")
Map<String, Object> fromIcTel = (Map<String, Object>) icTelMap.get(fromIc);
if (fromIcTel == null || fromIcTel.get("amt") == null) {
response.put("status", false);
response.put("statusMsg", "해당구간의 통행요금을 찾지 못했습니다.");
return response;
}
@SuppressWarnings("unchecked")
List<Integer> fromAmt = (List<Integer>) fromIcTel.get("amt");
Map<String, Object> fareData = new HashMap<>();
fareData.put("type", "open_both");
fareData.put("icName", fromIc);
fareData.put("fare1", fromAmt.get(0));
fareData.put("fare1Small", fromAmt.get(5));
fareData.put("fare2", fromAmt.get(1));
fareData.put("fare3", fromAmt.get(2));
fareData.put("fare4", fromAmt.get(3));
fareData.put("fare5", fromAmt.get(4));
response.put("status", true);
response.put("statusMsg", "정상응답");
response.put("fareData", fareData);
return response;
}
else if (fromCodeInt < 100) {
@SuppressWarnings("unchecked")
Map<String, Object> fromIcTel = (Map<String, Object>) icTelMap.get(fromIc);
if (fromIcTel == null || fromIcTel.get("amt") == null) {
response.put("status", false);
response.put("statusMsg", "해당구간의 통행요금을 찾지 못했습니다.");
return response;
}
@SuppressWarnings("unchecked")
List<Integer> fromAmt = (List<Integer>) fromIcTel.get("amt");
@SuppressWarnings("unchecked")
List<String> outIcs = (List<String>) fromIcTel.get("out");
Map<String, Object> fareData = new HashMap<>();
fareData.put("type", "open_from");
fareData.put("icName", fromIc);
fareData.put("toIc", toIc);
fareData.put("fare1", fromAmt.get(0));
fareData.put("fare1Small", fromAmt.get(5));
fareData.put("fare2", fromAmt.get(1));
fareData.put("fare3", fromAmt.get(2));
fareData.put("fare4", fromAmt.get(3));
fareData.put("fare5", fromAmt.get(4));
fareData.put("outIcs", outIcs);
response.put("status", true);
response.put("statusMsg", "정상응답");
response.put("fareData", fareData);
return response;
}
else if (toCodeInt < 100) {
@SuppressWarnings("unchecked")
Map<String, Object> toIcTel = (Map<String, Object>) icTelMap.get(toIc);
if (toIcTel == null || toIcTel.get("amt") == null) {
response.put("status", false);
response.put("statusMsg", "해당구간의 통행요금을 찾지 못했습니다.");
return response;
}
@SuppressWarnings("unchecked")
List<Integer> toAmt = (List<Integer>) toIcTel.get("amt");
@SuppressWarnings("unchecked")
List<String> outIcs = (List<String>) toIcTel.get("out");
Map<String, Object> fareData = new HashMap<>();
fareData.put("type", "open_to");
fareData.put("icName", toIc);
fareData.put("fromIc", fromIc);
fareData.put("fare1", toAmt.get(0));
fareData.put("fare1Small", toAmt.get(5));
fareData.put("fare2", toAmt.get(1));
fareData.put("fare3", toAmt.get(2));
fareData.put("fare4", toAmt.get(3));
fareData.put("fare5", toAmt.get(4));
fareData.put("outIcs", outIcs);
response.put("status", true);
response.put("statusMsg", "정상응답");
response.put("fareData", fareData);
return response;
}
else {
log.warn("{}에서 {} 통행요금을 찾을 수 없습니다", fromIc, toIc);
response.put("status", false);
response.put("statusMsg", "해당구간의 통행요금을 찾지 못했습니다.");
return response;
}
} catch (Exception e) {
log.error("통행요금 조회 에러 : {}", e.getMessage(), e);
response.put("status", false);
response.put("statusMsg", "서비스에 일시적인 장애가 발생하였습니다.");
response.put("errorMsg", e.getMessage());
return response;
}
}
}
@@ -0,0 +1,41 @@
package com.ex.chatbotapi.domain.rag.api;
import com.ex.chatbotapi.domain.rag.dto.RagQueryRequest;
import com.ex.chatbotapi.domain.rag.service.RagService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* RAG(Retrieval-Augmented Generation) 관련 API 컨트롤러
* HTTP 요청 처리만 담당하고, 비즈니스 로직은 RagService에 위임
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/rag-only")
@RequiredArgsConstructor
public class RagApiController {
private final RagService ragService;
/**
* 챗봇 사용자 질문을 RAG 서버로 전달하고 응답을 반환하는 엔드포인트
*/
@PostMapping
public ResponseEntity<Map<String, Object>> queryRag(@RequestBody RagQueryRequest request) {
log.info("API 호출 : queryRag(POST) - 질문: {}, 봇ID: {}",
request.getQuestion(), request.getBotId());
return ResponseEntity.ok(ragService.processQuestion(request));
}
/**
* RAG 서버(Docker 컨테이너) 헬스체크 엔드포인트
*/
@GetMapping("/health")
public ResponseEntity<Map<String, Object>> checkHealth() {
log.info("API 호출 : checkHealth(GET)");
return ResponseEntity.ok(ragService.checkHealth());
}
}
@@ -0,0 +1,76 @@
package com.ex.chatbotapi.domain.rag.client;
import com.ex.chatbotapi.config.RagConfig;
import com.ex.chatbotapi.domain.rag.dto.IntentAnalysisRequest;
import com.ex.chatbotapi.domain.rag.dto.IntentAnalysisResponse;
import com.ex.chatbotapi.domain.rag.dto.RagServerRequest;
import com.ex.chatbotapi.domain.rag.dto.RagServerResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
/**
* RAG 서버와의 통신을 담당하는 클라이언트
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RagServerClient {
private final RestTemplate restTemplate;
private final RagConfig ragConfig;
/**
* RAG 서버에 질문을 전송하고 응답을 받아오는 메소드
*/
public RagServerResponse sendQuestion(RagServerRequest request) {
String url = ragConfig.getFullUrl();
log.info("RAG 서버 요청 시작 - URL: {}, 질문: {}", url, request.getQuery());
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<RagServerRequest> entity = new HttpEntity<>(request, headers);
ResponseEntity<RagServerResponse> response = restTemplate.postForEntity(
url, entity, RagServerResponse.class);
log.info("RAG 서버 응답 성공 - Status: {}", response.getStatusCode());
return response.getBody();
} catch (RestClientException e) {
log.error("RAG 서버 통신 실패 - URL: {}, 에러: {}", url, e.getMessage(), e);
throw e;
}
}
/**
* RAG 서버의 LLM intent 분석 엔드포인트 호출.
*/
public IntentAnalysisResponse analyzeIntent(IntentAnalysisRequest request) {
String url = ragConfig.getIntentAnalyzeUrl();
log.info("RAG intent 분석 요청 시작 - URL: {}, 질문: {}", url, request.getQuery());
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<IntentAnalysisRequest> entity = new HttpEntity<>(request, headers);
ResponseEntity<IntentAnalysisResponse> response = restTemplate.postForEntity(
url, entity, IntentAnalysisResponse.class);
log.info("RAG intent 분석 응답 성공 - Status: {}", response.getStatusCode());
return response.getBody();
} catch (RestClientException e) {
log.error("RAG intent 분석 통신 실패 - URL: {}, 에러: {}", url, e.getMessage(), e);
throw e;
}
}
}
@@ -0,0 +1,23 @@
package com.ex.chatbotapi.domain.rag.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class IntentAnalysisRequest {
private String query;
private String botId;
private String pendingIntentType;
private Map<String, String> pendingParams;
private List<Map<String, Object>> intentDefinitions;
}
@@ -0,0 +1,25 @@
package com.ex.chatbotapi.domain.rag.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.HashMap;
import java.util.Map;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class IntentAnalysisResponse {
private String intentType;
private Double confidence;
private Map<String, String> params = new HashMap<>();
private String routeType;
private Boolean needsClarification;
private String clarificationQuestion;
private String reason;
public double confidenceOrZero() {
return confidence != null ? confidence : 0.0;
}
}
@@ -0,0 +1,27 @@
package com.ex.chatbotapi.domain.rag.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* 클라이언트(챗봇 웹)로부터 받는 RAG 질의 요청 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RagQueryRequest {
/** 사용자 질문 원본 */
private String question;
/** 사용자 봇 ID */
private String botId;
/**
* 도메인 서비스에서 조회한 DB 데이터 (intentType에 따라 설정)
* null이면 RAG 단독 처리
*/
private Map<String, Object> domainData;
/** 의도 타입 (프롬프트 컨텍스트 제공용) */
private String intentType;
}
@@ -0,0 +1,32 @@
package com.ex.chatbotapi.domain.rag.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* RAG 서버(Docker)로 전송하는 요청 DTO
* API 스펙: POST /ask
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class RagServerRequest {
/** 사용자 질문 (Docker API 필드명: "query") */
private String query;
/** 봇 ID */
private String botId;
/**
* 도메인 서비스 DB 조회 결과 (null이면 전송 안 함)
* Python 측 field: domain_data (alias: domainData)
*/
private Map<String, Object> domainData;
/** 의도 타입 (Python 측 field: intent_type) */
private String intentType;
}
@@ -0,0 +1,34 @@
package com.ex.chatbotapi.domain.rag.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
/**
* RAG 서버(Docker)로부터 받는 응답 DTO
* API 스펙: POST /ask 응답
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RagServerResponse {
/** LLM이 생성한 답변 */
private String answer;
/** 가장 유사한 질문 목록 */
@JsonProperty("matched_questions")
private List<String> matchedQuestions;
/** 유사도 점수 배열 */
private List<Double> scores;
/** 참조한 문서/질문 개수 */
@JsonProperty("num_references")
private Integer numReferences;
/** 공식 FAQ 상세 URL 목록 */
@JsonProperty("faq_urls")
private List<String> faqUrls;
/** 참조 문서 메타데이터 */
private List<Map<String, Object>> references;
}
@@ -0,0 +1,117 @@
package com.ex.chatbotapi.domain.rag.service;
import com.ex.chatbotapi.config.RagConfig;
import com.ex.chatbotapi.domain.rag.client.RagServerClient;
import com.ex.chatbotapi.domain.rag.dto.RagQueryRequest;
import com.ex.chatbotapi.domain.rag.dto.RagServerRequest;
import com.ex.chatbotapi.domain.rag.dto.RagServerResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;
/**
* RAG 관련 비즈니스 로직을 처리하는 서비스
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RagService {
private final RagServerClient ragServerClient;
private final RagConfig ragConfig;
private final RestTemplate restTemplate;
/**
* 사용자 질문을 처리하고 RAG 서버로부터 응답을 받아오는 메소드
*/
public Map<String, Object> processQuestion(RagQueryRequest request) {
log.info("RAG 질문 처리 시작 - 질문: {}, 봇ID: {}",
request.getQuestion(), request.getBotId());
Map<String, Object> result = new HashMap<>();
try {
validateRequest(request);
RagServerRequest serverRequest = RagServerRequest.builder()
.query(request.getQuestion())
.botId(request.getBotId())
.domainData(request.getDomainData())
.intentType(request.getIntentType())
.build();
RagServerResponse serverResponse = ragServerClient.sendQuestion(serverRequest);
result.put("status", true);
result.put("statusMsg", "정상응답");
result.put("answer", serverResponse.getAnswer());
result.put("matchedQuestions", serverResponse.getMatchedQuestions());
result.put("scores", serverResponse.getScores());
result.put("numReferences", serverResponse.getNumReferences());
result.put("faqUrls", serverResponse.getFaqUrls());
result.put("references", serverResponse.getReferences());
} catch (IllegalArgumentException e) {
log.warn("입력 검증 실패 - {}", e.getMessage());
result.put("status", false);
result.put("statusMsg", "입력값 오류: " + e.getMessage());
} catch (RestClientException e) {
log.error("RAG 서버 통신 실패 - {}", e.getMessage());
result.put("status", false);
result.put("statusMsg", "RAG 서버 통신 실패");
} catch (Exception e) {
log.error("RAG 질문 처리 중 예외 발생 - {}", e.getMessage(), e);
result.put("status", false);
result.put("statusMsg", "처리 중 오류 발생");
}
return result;
}
/**
* RAG 서버 헬스체크
*/
@SuppressWarnings({"unchecked", "rawtypes"})
public Map<String, Object> checkHealth() {
log.info("RAG 서버 헬스체크 시작");
Map<String, Object> result = new HashMap<>();
try {
String healthUrl = ragConfig.getHealthUrl();
log.info("헬스체크 URL: {}", healthUrl);
ResponseEntity<Map> response = restTemplate.getForEntity(healthUrl, Map.class);
Map<String, Object> healthData = (Map<String, Object>) response.getBody();
result.put("status", true);
result.put("statusMsg", "헬스체크 성공");
result.put("healthCheck", healthData);
if (healthData != null) {
log.info("RAG 서버 상태: {}, 벡터 개수: {}",
healthData.get("status"), healthData.get("vector_count"));
}
} catch (Exception e) {
log.error("RAG 서버 헬스체크 실패 - {}", e.getMessage(), e);
result.put("status", false);
result.put("statusMsg", "헬스체크 실패: " + e.getMessage());
}
return result;
}
private void validateRequest(RagQueryRequest request) {
if (request.getQuestion() == null || request.getQuestion().trim().isEmpty()) {
throw new IllegalArgumentException("질문이 비어있습니다.");
}
}
}
@@ -0,0 +1,31 @@
package com.ex.chatbotapi.domain.restarea.api;
import com.ex.chatbotapi.domain.restarea.service.RestAreaService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 휴게소 관련 API 컨트롤러
* HTTP 요청 처리만 담당하고, 비즈니스 로직은 RestAreaService에 위임
*/
@Slf4j
@RestController
@RequiredArgsConstructor
public class RestAreaApiController {
private final RestAreaService restAreaService;
/**
* Description : 휴게소 가격정보 조회 메소드
*/
@GetMapping("/api/restarea/priceInfo")
public ResponseEntity<Map<String, Object>> queryPriceInfo(String restAreaName) {
log.info("API 호출 : queryPriceInfo(GET) - restAreaName: {}", restAreaName);
return ResponseEntity.ok(restAreaService.getPriceInfo(restAreaName));
}
}
@@ -0,0 +1,65 @@
package com.ex.chatbotapi.domain.restarea.formatter;
import com.ex.chatbotapi.domain.agent.formatter.LlmDomainDataFormatter;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* BRAND_SHOP — 휴게소 브랜드 매장 목록을 LLM용 요약으로 정리한다.
*/
@Component
public class BrandShopLlmFormatter implements LlmDomainDataFormatter {
private static final int MAX_BRANDS = 20;
@Override
public String getIntentType() {
return "BRAND_SHOP";
}
@Override
@SuppressWarnings("unchecked")
public void enrich(Map<String, Object> domainData) {
if (domainData == null) return;
Object st = domainData.get("status");
boolean ok = st instanceof Boolean && (Boolean) st;
if (!ok) {
String msg = domainData.get("statusMsg") != null
? String.valueOf(domainData.get("statusMsg"))
: "브랜드 매장 정보를 조회할 수 없습니다.";
domainData.put("llmSummary", msg);
return;
}
String restAreaName = String.valueOf(domainData.getOrDefault("restAreaName", ""));
Object totalCountObj = domainData.get("totalCount");
int totalCount = totalCountObj instanceof Number ? ((Number) totalCountObj).intValue() : 0;
List<Map<String, Object>> list = (List<Map<String, Object>>) domainData.get("list");
if (list == null || list.isEmpty()) {
domainData.put("llmSummary", restAreaName + " 휴게소 매장 정보가 없습니다.");
return;
}
// 브랜드명 목록 (중복 제거)
List<String> brands = list.stream()
.map(b -> String.valueOf(b.getOrDefault("brdName", "")))
.distinct()
.limit(MAX_BRANDS)
.collect(Collectors.toList());
StringBuilder sb = new StringBuilder();
sb.append(restAreaName).append(" 휴게소 브랜드 매장 (총 ").append(totalCount).append("개):\n");
sb.append(String.join(", ", brands));
if (totalCount > MAX_BRANDS) {
sb.append(" 외 ").append(totalCount - MAX_BRANDS).append("개");
}
domainData.put("llmSummary", sb.toString().trim());
}
}
@@ -0,0 +1,59 @@
package com.ex.chatbotapi.domain.restarea.formatter;
import com.ex.chatbotapi.domain.agent.formatter.LlmDomainDataFormatter;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* REST_AREA_FOOD_LIST — 휴게소 음식 목록(전체 메뉴)을 LLM용 요약으로 정리한다.
*/
@Component
public class FoodListLlmFormatter implements LlmDomainDataFormatter {
private static final int MAX_CHARS = 800;
@Override
public String getIntentType() {
return "REST_AREA_FOOD_LIST";
}
@Override
@SuppressWarnings("unchecked")
public void enrich(Map<String, Object> domainData) {
if (domainData == null) return;
Object st = domainData.get("status");
boolean ok = st instanceof Boolean && (Boolean) st;
if (!ok) {
String msg = domainData.get("statusMsg") != null
? String.valueOf(domainData.get("statusMsg"))
: "음식 목록을 조회할 수 없습니다.";
domainData.put("llmSummary", msg);
return;
}
String restAreaName = String.valueOf(domainData.getOrDefault("restAreaName", ""));
Map<String, String> msgMap = (Map<String, String>) domainData.get("msgMap");
if (msgMap == null || msgMap.isEmpty()) {
domainData.put("llmSummary", restAreaName + " 휴게소 메뉴 정보가 없습니다.");
return;
}
StringBuilder sb = new StringBuilder();
sb.append(restAreaName).append(" 휴게소 메뉴:\n");
for (Map.Entry<String, String> entry : msgMap.entrySet()) {
String restaurant = entry.getKey().replace("휴게소", "").trim();
sb.append("\n[").append(restaurant).append("]\n");
sb.append(entry.getValue()).append("\n");
if (sb.length() > MAX_CHARS) {
sb.append("...(이하 생략)");
break;
}
}
domainData.put("llmSummary", sb.toString().trim());
}
}
@@ -0,0 +1,74 @@
package com.ex.chatbotapi.domain.restarea.formatter;
import com.ex.chatbotapi.domain.agent.formatter.LlmDomainDataFormatter;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* GASSTATION — 휴게소 주유소 현황을 LLM용 요약으로 정리한다.
*/
@Component
public class GasstationLlmFormatter implements LlmDomainDataFormatter {
@Override
public String getIntentType() {
return "GASSTATION";
}
@Override
@SuppressWarnings("unchecked")
public void enrich(Map<String, Object> domainData) {
if (domainData == null) return;
Object st = domainData.get("status");
boolean ok = st instanceof Boolean && (Boolean) st;
if (!ok) {
String msg = domainData.get("statusMsg") != null
? String.valueOf(domainData.get("statusMsg"))
: "주유소 정보를 조회할 수 없습니다.";
domainData.put("llmSummary", msg);
return;
}
String restAreaName = String.valueOf(domainData.getOrDefault("restAreaName", ""));
List<Map<String, Object>> list = (List<Map<String, Object>>) domainData.get("list");
if (list == null || list.isEmpty()) {
domainData.put("llmSummary", restAreaName + " 휴게소 주유 정보가 없습니다.");
return;
}
StringBuilder sb = new StringBuilder();
for (Map<String, Object> g : list) {
String svcNm = gasstationDisplayName(restAreaName, g);
String gasoline = String.valueOf(g.getOrDefault("gasolinePrice", "X"));
String diesel = String.valueOf(g.getOrDefault("diselPrice", "X"));
String lpgYn = String.valueOf(g.getOrDefault("lpgYn", "N"));
String lpgPrice = String.valueOf(g.getOrDefault("lpgPrice", "X"));
sb.append(svcNm).append(" 주유 현황:\n");
sb.append("- 휘발유: ").append("X".equals(gasoline) ? "정보 없음" : gasoline + "원").append("\n");
sb.append("- 경유: ").append("X".equals(diesel) ? "정보 없음" : diesel + "원").append("\n");
sb.append("- LPG: ").append("Y".equals(lpgYn) ? lpgPrice + "원" : "미운영").append("\n");
}
domainData.put("llmSummary", sb.toString().trim());
}
private String gasstationDisplayName(String restAreaName, Map<String, Object> gasInfo) {
Object serviceAreaName = gasInfo.get("serviceAreaName");
if (serviceAreaName != null) {
return String.valueOf(serviceAreaName)
.replace("주유소", "")
.replace("휴게소", "");
}
Object directionObj = gasInfo.get("direction");
if (directionObj != null && restAreaName != null && !restAreaName.trim().isEmpty()) {
return restAreaName + "(" + String.valueOf(directionObj).trim() + ")";
}
return "주유소";
}
}
@@ -0,0 +1,80 @@
package com.ex.chatbotapi.domain.restarea.formatter;
import com.ex.chatbotapi.domain.agent.formatter.LlmDomainDataFormatter;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* REST_AREA_PRICE — 휴게소 간식·메뉴 가격 공시(구간별 min/max/avg)를 LLM용 요약으로 정리한다.
*/
@Component
public class RestAreaPriceLlmFormatter implements LlmDomainDataFormatter {
@Override
public String getIntentType() {
return "REST_AREA_PRICE";
}
@Override
public void enrich(Map<String, Object> domainData) {
if (domainData == null || domainData.isEmpty()) {
return;
}
Object st = domainData.get("status");
boolean ok = st instanceof Boolean && (Boolean) st;
if (!ok) {
String msg = domainData.get("statusMsg") != null
? String.valueOf(domainData.get("statusMsg"))
: "휴게소 가격정보 조회에 실패했습니다.";
domainData.put("llmSummary", msg);
return;
}
@SuppressWarnings("unchecked")
Map<String, List<Map<String, String>>> priceInfo =
(Map<String, List<Map<String, String>>>) domainData.get("priceInfo");
if (priceInfo == null || priceInfo.isEmpty()) {
domainData.put("llmSummary", "가격 공시(priceInfo) 데이터가 없습니다.");
return;
}
StringBuilder sb = new StringBuilder();
sb.append("휴게소 메뉴·간식 가격 공시 요약:\n");
int restLimit = 5;
int itemLimitPerRest = 12;
int restCount = 0;
for (Map.Entry<String, List<Map<String, String>>> e : priceInfo.entrySet()) {
if (restCount >= restLimit) {
sb.append("- …외 휴게소 ").append(priceInfo.size() - restLimit).append("곳 더 있음\n");
break;
}
String restNm = e.getKey();
List<Map<String, String>> rows = e.getValue();
sb.append("- ").append(restNm).append("\n");
int n = 0;
for (Map<String, String> row : rows) {
if (n >= itemLimitPerRest) {
sb.append(" · …외 ").append(rows.size() - itemLimitPerRest).append("개 항목\n");
break;
}
String gb = row.getOrDefault("snackGb", "?");
String min = row.getOrDefault("minCost", "?");
String max = row.getOrDefault("maxCost", "?");
String avg = row.getOrDefault("avgCost", "?");
sb.append(" · ").append(gb)
.append(": 최저 ").append(min).append("원, 최고 ").append(max)
.append("원, 평균 ").append(avg).append("원\n");
n++;
}
restCount++;
}
domainData.put("llmSummary", sb.toString().trim());
}
}
@@ -0,0 +1,25 @@
package com.ex.chatbotapi.domain.restarea.handler;
import com.ex.chatbotapi.domain.agent.handler.IntentHandler;
import com.ex.chatbotapi.domain.restarea.service.RestAreaService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class RestAreaPriceHandler implements IntentHandler {
private final RestAreaService restAreaService;
@Override
public String getIntentType() {
return "REST_AREA_PRICE";
}
@Override
public Map<String, Object> fetchDomainData(Map<String, String> params) {
return restAreaService.getPriceInfo(params.get("restAreaName"));
}
}
@@ -0,0 +1,45 @@
package com.ex.chatbotapi.domain.restarea.repository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import javax.sql.DataSource;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* 휴게소 관련 데이터 접근 레이어
*/
@Slf4j
@Repository
@RequiredArgsConstructor
public class RestAreaRepository {
@Qualifier("restAreaDataSource")
private final DataSource restAreaDataSource;
/**
* 휴게소 가격정보 조회
*/
public List<Map<String, Object>> findPriceInfoByRestAreaName(String restAreaName) {
String query = "SELECT REST_NM, NM_REP as SNACK_GB, MIN_COST, MAX_COST, AVG_COST " +
"FROM EXREST.RESTCM_SNACKRESTAVG_VW A " +
"INNER JOIN EXREST.RESTCM_SNACKGB_VW B " +
"ON A.SNACK_GB = B.NM_CD " +
"WHERE REST_NM LIKE ?";
JdbcTemplate jdbcTemplate = new JdbcTemplate(restAreaDataSource);
try {
String searchPattern = "%" + restAreaName + "%";
return jdbcTemplate.queryForList(query, searchPattern);
} catch (EmptyResultDataAccessException e) {
return Collections.emptyList();
}
}
}
@@ -0,0 +1,78 @@
package com.ex.chatbotapi.domain.restarea.service;
import com.ex.chatbotapi.domain.restarea.repository.RestAreaRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 휴게소 관련 비즈니스 로직 처리 서비스
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RestAreaService {
private final RestAreaRepository restAreaRepository;
/**
* 휴게소 가격정보 조회
*/
public Map<String, Object> getPriceInfo(String restAreaName) {
log.info("{} 휴게소 가격공시 조회를 시작합니다", restAreaName);
Map<String, Object> response = new HashMap<>();
try {
List<Map<String, Object>> restAreaInfo = restAreaRepository
.findPriceInfoByRestAreaName(restAreaName);
if (!restAreaInfo.isEmpty()) {
Map<String, List<Map<String, String>>> priceInfoByRestArea = new HashMap<>();
for (Map<String, Object> map : restAreaInfo) {
String restNm = (String) map.get("REST_NM");
String snackGb = (String) map.get("SNACK_GB");
int minCost = ((BigDecimal) map.get("MIN_COST")).intValue();
int maxCost = ((BigDecimal) map.get("MAX_COST")).intValue();
int avgCost = ((BigDecimal) map.get("AVG_COST")).intValue();
if (!priceInfoByRestArea.containsKey(restNm)) {
priceInfoByRestArea.put(restNm, new ArrayList<>());
}
Map<String, String> priceItem = new HashMap<>();
priceItem.put("snackGb", snackGb);
priceItem.put("minCost", String.valueOf(minCost));
priceItem.put("maxCost", String.valueOf(maxCost));
priceItem.put("avgCost", String.valueOf(avgCost));
priceInfoByRestArea.get(restNm).add(priceItem);
}
response.put("status", true);
response.put("statusMsg", "정상응답");
response.put("priceInfo", priceInfoByRestArea);
} else {
log.info("{} 휴게소의 메뉴 및 가격정보가 없습니다.", restAreaName);
response.put("status", false);
response.put("statusMsg", restAreaName + " 휴게소의 메뉴 및 가격정보가 없습니다.");
}
return response;
} catch (Exception e) {
log.error("가격정보 조회 에러 : {}", e.getMessage(), e);
response.put("status", false);
response.put("statusMsg", "서비스에 일시적인 장애가 발생하였습니다.");
response.put("errorMsg", e.getMessage());
return response;
}
}
}
@@ -0,0 +1,41 @@
package com.ex.chatbotapi.domain.troad.api;
import com.ex.chatbotapi.domain.troad.service.TroadService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.util.Map;
/**
* 도로교통 관련 API 컨트롤러 (경로 조회, 혼잡구간, 교통속보)
* HTTP 요청 처리만 담당하고, 비즈니스 로직은 TroadService에 위임
*/
@Slf4j
@RestController
@RequiredArgsConstructor
public class TroadApiController {
private final TroadService troadService;
@GetMapping("/api/troad/searchRoute")
public ResponseEntity<Map<String, Object>> querySearchRoute(String fromIc, String toIc) throws IOException {
log.info("API 호출 : querySearchRoute(GET) - fromIc: {}, toIc: {}", fromIc, toIc);
return ResponseEntity.ok(troadService.searchRoute(fromIc, toIc));
}
@GetMapping("/api/troad/congestion")
public ResponseEntity<Map<String, Object>> queryCongestion(String routeName) {
log.info("API 호출 : queryCongestion(GET) - routeName: {}", routeName);
return ResponseEntity.ok(troadService.getCongestion(routeName));
}
@GetMapping("/api/troad/breakingNews")
public ResponseEntity<Map<String, Object>> queryBreakingNews(String routeName) {
log.info("API 호출 : queryBreakingNews(GET) - routeName: {}", routeName);
return ResponseEntity.ok(troadService.getBreakingNews(routeName));
}
}
@@ -0,0 +1,95 @@
package com.ex.chatbotapi.domain.troad.formatter;
import com.ex.chatbotapi.domain.agent.formatter.LlmDomainDataFormatter;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* TROAD_BREAKING_NEWS — 노선별 교통속보(상·하행)를 LLM용 요약으로 정리한다.
*/
@Component
public class TroadBreakingNewsLlmFormatter implements LlmDomainDataFormatter {
private static final String REGEX_DIST = "\\(\\d+(\\.\\d+)?K\\)";
@Override
public String getIntentType() {
return "TROAD_BREAKING_NEWS";
}
@Override
public void enrich(Map<String, Object> domainData) {
if (domainData == null || domainData.isEmpty()) {
return;
}
Object st = domainData.get("status");
boolean ok = st instanceof Boolean && (Boolean) st;
if (!ok) {
String msg = domainData.get("statusMsg") != null
? String.valueOf(domainData.get("statusMsg"))
: "교통속보 조회에 실패했습니다.";
domainData.put("llmSummary", msg);
return;
}
@SuppressWarnings("unchecked")
Map<String, Object> routeInfo = (Map<String, Object>) domainData.get("RouteInfo");
@SuppressWarnings("unchecked")
List<Map<String, Object>> newsS = (List<Map<String, Object>>) domainData.get("RouteBreakingNewsS");
@SuppressWarnings("unchecked")
List<Map<String, Object>> newsE = (List<Map<String, Object>>) domainData.get("RouteBreakingNewsE");
if (routeInfo == null || routeInfo.isEmpty()) {
domainData.put("llmSummary", "노선 기본 정보(RouteInfo)가 없습니다.");
return;
}
String routeNm = String.valueOf(routeInfo.getOrDefault("ROUTENM", "?"));
StringBuilder sb = new StringBuilder();
sb.append("노선 교통속보 요약 — ").append(routeNm).append("\n");
int cap = 10;
appendNewsBlock(sb, "상행(S)", newsS, cap);
appendNewsBlock(sb, "하행(E)", newsE, cap);
domainData.put("llmSummary", sb.toString().trim());
}
private static void appendNewsBlock(StringBuilder sb, String label, List<Map<String, Object>> rows, int cap) {
sb.append("\n【").append(label).append("】\n");
if (rows == null || rows.isEmpty()) {
sb.append("- 속보 없음\n");
return;
}
int n = 0;
for (Map<String, Object> map : rows) {
if (n >= cap) {
sb.append("- …외 ").append(rows.size() - cap).append("건\n");
break;
}
String inciTp = String.valueOf(map.getOrDefault("INCI_TP", ""));
String when = String.valueOf(map.getOrDefault("INCIDENT_DATE", ""));
String pos = String.valueOf(map.getOrDefault("INCIDENT_POS_NAME", ""));
pos = pos.replaceAll(REGEX_DIST, "").trim();
String msg = String.valueOf(map.getOrDefault("MSG", ""));
msg = msg.replaceAll("\\s+", " ").trim();
sb.append("- ");
if (!when.isEmpty() && !"null".equals(when)) {
sb.append(when).append(" ");
}
if (!inciTp.isEmpty() && !"null".equals(inciTp)) {
sb.append("[").append(inciTp).append("] ");
}
if (!pos.isEmpty() && !"null".equals(pos)) {
sb.append(pos).append(" — ");
}
sb.append(msg.isEmpty() || "null".equals(msg) ? "(내용참조)" : msg).append("\n");
n++;
}
}
}
@@ -0,0 +1,125 @@
package com.ex.chatbotapi.domain.troad.formatter;
import com.ex.chatbotapi.domain.agent.formatter.LlmDomainDataFormatter;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* TROAD_CONGESTION — 노선별 혼잡 구간(상·하행)을 LLM용 요약으로 정리한다.
*/
@Component
public class TroadCongestionLlmFormatter implements LlmDomainDataFormatter {
@Override
public String getIntentType() {
return "TROAD_CONGESTION";
}
@Override
public void enrich(Map<String, Object> domainData) {
if (domainData == null || domainData.isEmpty()) {
return;
}
Object st = domainData.get("status");
boolean ok = st instanceof Boolean && (Boolean) st;
if (!ok) {
String msg = domainData.get("statusMsg") != null
? String.valueOf(domainData.get("statusMsg"))
: "노선 혼잡정보 조회에 실패했습니다.";
domainData.put("llmSummary", msg);
return;
}
@SuppressWarnings("unchecked")
Map<String, Object> routeInfo = (Map<String, Object>) domainData.get("RouteInfo");
@SuppressWarnings("unchecked")
List<Map<String, Object>> routeInfoS = (List<Map<String, Object>>) domainData.get("RouteInfoS");
@SuppressWarnings("unchecked")
List<Map<String, Object>> routeInfoE = (List<Map<String, Object>>) domainData.get("RouteInfoE");
if (routeInfo == null || routeInfo.isEmpty()) {
domainData.put("llmSummary", "노선 기본 정보(RouteInfo)가 없습니다.");
return;
}
String routeNm = String.valueOf(routeInfo.getOrDefault("ROUTENM", "?"));
StringBuilder sb = new StringBuilder();
sb.append("노선 혼잡 구간 요약 — ").append(routeNm).append("\n");
int cap = 12;
if (routeInfoS != null && !routeInfoS.isEmpty()) {
appendDirectionBlock(sb, "상행 등(S)", routeInfoS, cap);
} else {
sb.append("\n【상행 등혼잡 구간】\n- 해당 방향 데이터 없음\n");
}
if (routeInfoE != null && !routeInfoE.isEmpty()) {
appendDirectionBlock(sb, "하행 등(E)", routeInfoE, cap);
} else {
sb.append("\n【하행 등혼잡 구간】\n- 해당 방향 데이터 없음\n");
}
domainData.put("llmSummary", sb.toString().trim());
}
private static void appendDirectionBlock(StringBuilder sb, String label, List<Map<String, Object>> rows, int cap) {
sb.append("\n【").append(label).append("】\n");
int n = 0;
for (Map<String, Object> map : rows) {
if (n >= cap) {
sb.append("- …외 ").append(rows.size() - cap).append("건\n");
break;
}
String node = String.valueOf(map.getOrDefault("NODE_NM", "?"));
String grade = gradeToKo(map.get("TRAFFIC_GRADE"));
double len = toDouble(map.get("LINK_LEN_KM"));
double speed = toDouble(map.get("SPEED"));
String dirNm = String.valueOf(map.getOrDefault("ROUTE_DIR_NM", ""));
sb.append("- ").append(node).append(" (").append(String.format("%.1f", len)).append("km) — ")
.append(grade).append(", 평균속도 ").append(String.format("%.0f", speed)).append("km/h");
if (!dirNm.isEmpty() && !"?".equals(dirNm)) {
sb.append(", ").append(dirNm);
}
sb.append("\n");
n++;
}
if (rows.isEmpty()) {
sb.append("- 해당 방향 데이터 없음\n");
}
}
private static String gradeToKo(Object code) {
if (code == null) {
return "정보없음";
}
String g = code.toString();
switch (g) {
case "G":
return "원활";
case "L":
return "서행";
case "R":
return "정체";
default:
return g;
}
}
private static double toDouble(Object v) {
if (v == null) {
return 0;
}
if (v instanceof Number) {
return ((Number) v).doubleValue();
}
try {
return Double.parseDouble(v.toString());
} catch (NumberFormatException e) {
return 0;
}
}
}
@@ -0,0 +1,149 @@
package com.ex.chatbotapi.domain.troad.formatter;
import com.ex.chatbotapi.domain.agent.formatter.LlmDomainDataFormatter;
import com.google.gson.Gson;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* TROAD_SEARCH_ROUTE — 시터스 영업소 간 경로(Summary, 정체/사고/작업 Icdt 등)를 LLM용 요약으로 정리한다.
*/
@Component
public class TroadSearchRouteLlmFormatter implements LlmDomainDataFormatter {
private static final Gson GSON = new Gson();
private static final String REGEX_DIST = "\\(\\d+(\\.\\d+)?K\\)";
private static final String REGEX_TIME = "\\[\\d{2}:\\d{2}-\\s*:\\s*\\]";
@Override
public String getIntentType() {
return "TROAD_SEARCH_ROUTE";
}
@Override
public void enrich(Map<String, Object> domainData) {
if (domainData == null || domainData.isEmpty()) {
return;
}
Object st = domainData.get("status");
boolean ok = st instanceof Boolean && (Boolean) st;
if (!ok) {
String msg = domainData.get("statusMsg") != null
? String.valueOf(domainData.get("statusMsg"))
: "영업소 간 교통경로 조회에 실패했습니다.";
domainData.put("llmSummary", msg);
return;
}
@SuppressWarnings("unchecked")
Map<String, Object> summary = (Map<String, Object>) domainData.get("Summary");
@SuppressWarnings("unchecked")
Map<String, Object> bunch = (Map<String, Object>) domainData.get("Bunch");
@SuppressWarnings("unchecked")
Map<String, Object> icdt = (Map<String, Object>) domainData.get("Icdt");
String srcName = String.valueOf(domainData.getOrDefault("SrcName", ""));
String dstName = String.valueOf(domainData.getOrDefault("DstName", ""));
StringBuilder sb = new StringBuilder();
sb.append("영업소 구간 교통요약\n");
if (!srcName.isEmpty() || !dstName.isEmpty()) {
sb.append("- 구간: ").append(srcName).append(" ⇒ ").append(dstName).append("\n");
}
if (summary != null && !summary.isEmpty()) {
double routeKm = toDouble(summary.get("RouteLength")) / 1000.0;
double travelSec = toDouble(summary.get("TravelTime"));
int hh = (int) (travelSec / 3600);
int mm = (int) ((travelSec % 3600) / 60);
sb.append("- 총거리: 약 ").append(Math.round(routeKm)).append("km\n");
sb.append("- 소요시간(엔진 추정): ").append(hh).append("시간 ").append(mm).append("분\n");
}
if (bunch != null && bunch.containsKey("BunchItems")) {
sb.append("- 구간내 이벤트·구간 묶음 건수(BunchItems): ").append((int) toDouble(bunch.get("BunchItems"))).append("건\n");
}
sb.append("\n【정체】\n");
appendIcdtSection(sb, icdt, "정체");
sb.append("\n【사고】\n");
appendIcdtSection(sb, icdt, "사고");
sb.append("\n【작업】\n");
appendIcdtSection(sb, icdt, "작업");
domainData.put("llmSummary", sb.toString().trim());
}
private static void appendIcdtSection(StringBuilder sb, Map<String, Object> icdt, String type) {
List<String> lines = extractIcdtMessages(icdt, type, 25);
if (lines.isEmpty()) {
sb.append("- 없음\n");
return;
}
for (String line : lines) {
sb.append("» ").append(line).append("\n");
}
}
/**
* Kakao 스킬과 동일한 IcdtData·IcdtInfo 구조를 사용해 타입별 메시지 문자열을 수집한다.
*/
@SuppressWarnings("unchecked")
private static List<String> extractIcdtMessages(Map<String, Object> icdt, String incidentType, int maxLines) {
List<String> out = new ArrayList<>();
if (icdt == null) {
return out;
}
Object raw = icdt.get("IcdtData");
if (!(raw instanceof List)) {
return out;
}
List<Object> icdtDatas = (List<Object>) raw;
for (Object icdtData : icdtDatas) {
if (out.size() >= maxLines) {
break;
}
Map<String, Object> icdtDataMap = GSON.fromJson(GSON.toJson(icdtData), Map.class);
Object ii = icdtDataMap.get("IcdtInfo");
if (!(ii instanceof List)) {
continue;
}
List<?> icdtInfo = (List<?>) ii;
if (icdtInfo.size() < 4) {
continue;
}
String category = String.valueOf(icdtInfo.get(2));
if (!incidentType.equals(category)) {
continue;
}
String infoMsg = String.valueOf(icdtInfo.get(3));
infoMsg = infoMsg.replaceAll(REGEX_DIST, "");
infoMsg = infoMsg.replaceAll(REGEX_TIME, "");
infoMsg = infoMsg.trim();
if (!infoMsg.isEmpty()) {
out.add(infoMsg);
}
}
return out;
}
private static double toDouble(Object v) {
if (v == null) {
return 0;
}
if (v instanceof Number) {
return ((Number) v).doubleValue();
}
try {
return Double.parseDouble(v.toString());
} catch (NumberFormatException e) {
return 0;
}
}
}
@@ -0,0 +1,25 @@
package com.ex.chatbotapi.domain.troad.handler;
import com.ex.chatbotapi.domain.agent.handler.IntentHandler;
import com.ex.chatbotapi.domain.troad.service.TroadService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class TroadBreakingNewsHandler implements IntentHandler {
private final TroadService troadService;
@Override
public String getIntentType() {
return "TROAD_BREAKING_NEWS";
}
@Override
public Map<String, Object> fetchDomainData(Map<String, String> params) {
return troadService.getBreakingNews(params.get("routeName"));
}
}
@@ -0,0 +1,25 @@
package com.ex.chatbotapi.domain.troad.handler;
import com.ex.chatbotapi.domain.agent.handler.IntentHandler;
import com.ex.chatbotapi.domain.troad.service.TroadService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class TroadCongestionHandler implements IntentHandler {
private final TroadService troadService;
@Override
public String getIntentType() {
return "TROAD_CONGESTION";
}
@Override
public Map<String, Object> fetchDomainData(Map<String, String> params) {
return troadService.getCongestion(params.get("routeName"));
}
}
@@ -0,0 +1,32 @@
package com.ex.chatbotapi.domain.troad.handler;
import com.ex.chatbotapi.domain.agent.handler.IntentHandler;
import com.ex.chatbotapi.domain.troad.service.TroadService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class TroadSearchRouteHandler implements IntentHandler {
private final TroadService troadService;
@Override
public String getIntentType() {
return "TROAD_SEARCH_ROUTE";
}
@Override
public Map<String, Object> fetchDomainData(Map<String, String> params) {
try {
String fromIc = params.get("fromIcCode") != null ? params.get("fromIcCode") : params.get("fromIc");
String toIc = params.get("toIcCode") != null ? params.get("toIcCode") : params.get("toIc");
return troadService.searchRoute(fromIc, toIc);
} catch (IOException e) {
throw new RuntimeException("시터스 엔진 조회 실패: " + e.getMessage(), e);
}
}
}
@@ -0,0 +1,255 @@
package com.ex.chatbotapi.domain.troad.service;
import com.google.gson.Gson;
import com.google.gson.internal.LinkedTreeMap;
import com.mongodb.BasicDBObject;
import com.mongodb.client.MongoCollection;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.bson.Document;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.*;
@Slf4j
@Service
@RequiredArgsConstructor
public class TroadService {
private final MongoTemplate mongoTemplate;
@Qualifier("troadTemplate")
private final JdbcTemplate troadTemplate;
private final List<String> exceptionRoute = Arrays.asList("남해선", "서울문산선", "중부내륙선", "호남선", "수도권제2순환선");
/**
* 영업소(톨게이트) 간 경로 조회
*/
public Map<String, Object> searchRoute(String fromIc, String toIc) throws IOException {
BasicDBObject condition = new BasicDBObject();
condition.put("src", fromIc);
condition.put("dst", toIc);
Gson gson = new Gson();
MongoCollection<Document> icRoadInfo = mongoTemplate.getCollection("icRoadInfo");
Document icMinGRS = icRoadInfo.find(condition).first();
Map<String, Object> response = new HashMap<>();
if (icMinGRS != null) {
String srcGrs = icMinGRS.getString("srcGrs80");
String srcName = icMinGRS.getString("srcName");
String dstGrs = icMinGRS.getString("dstGrs80");
String dstName = icMinGRS.getString("dstName");
log.info("시터스 엔진 조회");
String result = extractRouteInfo(srcGrs, dstGrs);
Map<String, Object> resMap = gson.fromJson(result, Map.class);
@SuppressWarnings("unchecked")
Map<String, Object> summary = (LinkedTreeMap<String, Object>) resMap.get("Summary");
@SuppressWarnings("unchecked")
Map<String, Object> icdt = (LinkedTreeMap<String, Object>) resMap.get("Icdt");
@SuppressWarnings("unchecked")
Map<String, Object> bunch = (LinkedTreeMap<String, Object>) resMap.get("Bunch");
response.put("status", true);
response.put("statusMsg", "정상응답");
response.put("Summary", summary);
response.put("Icdt", icdt);
response.put("Bunch", bunch);
response.put("SrcName", srcName);
response.put("SrcGrs", srcGrs);
response.put("DstName", dstName);
response.put("DstGrs", dstGrs);
} else {
response.put("status", false);
response.put("statusMsg", "몽고db에 해당구간 존재안함");
log.error("시터스 엔진 조회 에러(몽고db에 해당구간 존재안함) : {} - {}", fromIc, toIc);
}
return response;
}
/**
* 노선별 혼잡구간 조회
*/
public Map<String, Object> getCongestion(String routeName) {
String queryGetRouteInfo = "SELECT ROUTENM, ROUTECD, GRPID FROM TROAD_APP.V_ROUTE_NEW_NM WHERE ROUTENM = ? GROUP BY ROUTENM, ROUTECD, GRPID";
String queryGetRouteQuickReplyInfo = "SELECT ROUTENM, ROUTECD, GRPID FROM TROAD_APP.V_ROUTE_NEW_NM WHERE ROUTENM LIKE ? GROUP BY ROUTENM, ROUTECD, GRPID";
String queryGetRouteCongestionInfoS = "SELECT RPRS_ROTNM, ROUTE_DIR_NM, NODE_NM, TRAFFIC_GRADE, LINK_LEN_KM, SPEED FROM ( SELECT ROW_NUMBER() OVER( PARTITION BY A.DRIVE_DRCT_CD, A.ROUTE_CD, A.GRP_ID ORDER BY A.DRIVE_DRCT_CD, A.ROUTE_CD, A.SECT_SQNO ASC ) AS STNUMB , ROW_NUMBER() OVER( PARTITION BY A.DRIVE_DRCT_CD, A.ROUTE_CD, A.GRP_ID ORDER BY A.DRIVE_DRCT_CD, A.ROUTE_CD, A.SECT_SQNO DESC ) AS EDNUMB , ROW_NUMBER() OVER( PARTITION BY A.CONZONE_ID, A.DRIVE_DRCT_CD ORDER BY CASE WHEN A.ROUTE_CD='3010' THEN A.SECT_SQNO END DESC, CASE WHEN A.ROUTE_CD='1000' THEN A.SECT_SQNO END , CASE WHEN MOD(B.ROUTESHORTNO,2)=1 THEN A.SECT_SQNO END, CASE WHEN MOD(B.ROUTESHORTNO,2)=0 THEN A.SECT_SQNO END DESC ) AS DETAIL_NO , ROW_NUMBER() OVER( PARTITION BY A.CONZONE_ID, A.DRIVE_DRCT_CD ORDER BY CASE WHEN A.ROUTE_CD='3010' THEN A.SECT_SQNO END, CASE WHEN A.ROUTE_CD='1000' THEN A.SECT_SQNO END DESC, CASE WHEN MOD(B.ROUTESHORTNO,2)=1 THEN A.SECT_SQNO END DESC, CASE WHEN MOD(B.ROUTESHORTNO,2)=0 THEN A.SECT_SQNO END ) AS DETAIL_NO2 , A.ROUTESORT, A.SECT_SQNO, B.ROUTEDIRNM AS ROUTE_DIR_NM , B.ROUTENM AS RPRS_ROTNM, A.ROUTE_CD, A.GRP_ID, A.DRIVE_DRCT_CD, A.NODE_NM, A.CONZONE_ID, A.ROUTE_NO_LIST, A.LINK_LEN_KM, A.BUS_YN, A.SPEED, A.TRAFFIC_GRADE, A.TRAFFIC_GRADE_BUS, A.CCTV_ID, A.CCTV_AREA, A.HLS_URL, A.FILEURL_IMG, A.INCIDENT_TYPE_CD, A.INCIDENT_DESC, A.MSG, A.INCIDENT_POS_NAME, A.DETR_ID, A.SPEED_DETR, A.TRAFFIC_GRADE_DETR, A.RESTAREA_ID, A.RESTAREA_NM, A.CONZONE_X, A.CONZONE_Y, A.WORK_CD, A.VVALUE01, A.VVALUE02, A.VVALUE03, A.VVALUE04, A.VVALUE05, A.VVALUE06, A.VVALUE07, A.VVALUE08, A.VVALUE09, CASE WHEN A.ROUTE_CD='3010' THEN 0 WHEN A.ROUTE_CD='1000' THEN 1 ELSE MOD(B.ROUTESHORTNO,2) END AS BGUBUN FROM V_ROUTE_TRFC_VER001 A LEFT OUTER JOIN V_ROUTE_NEW_NM B ON B.ROUTECD = A.ROUTE_CD AND B.GRPID = A.GRP_ID AND B.UPDOWNDIR = A.DRIVE_DRCT_CD WHERE 1=1 AND A.GRP_ID = ? ) WHERE 1=1 AND ROUTE_CD = ? AND DRIVE_DRCT_CD = 'S' AND GRP_ID = ? AND TRAFFIC_GRADE IN ('R','L') AND DETAIL_NO = '1' ORDER BY CASE WHEN BGUBUN=1 THEN STNUMB END, CASE WHEN BGUBUN=0 THEN STNUMB END DESC ";
String queryGetRouteCongestionInfoE = "SELECT RPRS_ROTNM, ROUTE_DIR_NM, NODE_NM, TRAFFIC_GRADE, LINK_LEN_KM, SPEED FROM ( SELECT ROW_NUMBER() OVER( PARTITION BY A.DRIVE_DRCT_CD, A.ROUTE_CD, A.GRP_ID ORDER BY A.DRIVE_DRCT_CD, A.ROUTE_CD, A.SECT_SQNO ASC ) AS STNUMB , ROW_NUMBER() OVER( PARTITION BY A.DRIVE_DRCT_CD, A.ROUTE_CD, A.GRP_ID ORDER BY A.DRIVE_DRCT_CD, A.ROUTE_CD, A.SECT_SQNO DESC ) AS EDNUMB , ROW_NUMBER() OVER( PARTITION BY A.CONZONE_ID, A.DRIVE_DRCT_CD ORDER BY CASE WHEN A.ROUTE_CD='3010' THEN A.SECT_SQNO END, CASE WHEN A.ROUTE_CD='1000' THEN A.SECT_SQNO END DESC, CASE WHEN MOD(B.ROUTESHORTNO,2)=1 THEN A.SECT_SQNO END DESC, CASE WHEN MOD(B.ROUTESHORTNO,2)=0 THEN A.SECT_SQNO END ) AS DETAIL_NO , ROW_NUMBER() OVER( PARTITION BY A.CONZONE_ID, A.DRIVE_DRCT_CD ORDER BY CASE WHEN A.ROUTE_CD='3010' THEN A.SECT_SQNO END DESC, CASE WHEN A.ROUTE_CD='1000' THEN A.SECT_SQNO END , CASE WHEN MOD(B.ROUTESHORTNO,2)=1 THEN A.SECT_SQNO END, CASE WHEN MOD(B.ROUTESHORTNO,2)=0 THEN A.SECT_SQNO END DESC ) AS DETAIL_NO2 , A.ROUTESORT, A.SECT_SQNO, B.ROUTEDIRNM AS ROUTE_DIR_NM , B.ROUTENM AS RPRS_ROTNM, A.ROUTE_CD, A.GRP_ID, A.DRIVE_DRCT_CD, A.NODE_NM, A.CONZONE_ID, A.ROUTE_NO_LIST, A.LINK_LEN_KM, A.BUS_YN, A.SPEED, A.TRAFFIC_GRADE, A.TRAFFIC_GRADE_BUS, A.CCTV_ID, A.CCTV_AREA, A.HLS_URL, A.FILEURL_IMG, A.INCIDENT_TYPE_CD, A.INCIDENT_DESC, A.MSG, A.INCIDENT_POS_NAME, A.DETR_ID, A.SPEED_DETR, A.TRAFFIC_GRADE_DETR, A.RESTAREA_ID, A.RESTAREA_NM, A.CONZONE_X, A.CONZONE_Y, A.WORK_CD, A.VVALUE01, A.VVALUE02, A.VVALUE03, A.VVALUE04, A.VVALUE05, A.VVALUE06, A.VVALUE07, A.VVALUE08, A.VVALUE09, CASE WHEN A.ROUTE_CD='3010' THEN 0 WHEN A.ROUTE_CD='1000' THEN 1 ELSE MOD(B.ROUTESHORTNO,2) END AS BGUBUN FROM V_ROUTE_TRFC_VER001 A LEFT OUTER JOIN V_ROUTE_NEW_NM B ON B.ROUTECD = A.ROUTE_CD AND B.GRPID = A.GRP_ID AND B.UPDOWNDIR = A.DRIVE_DRCT_CD WHERE 1=1 AND A.GRP_ID = ? ) WHERE 1=1 AND ROUTE_CD = ? AND DRIVE_DRCT_CD = 'E' AND GRP_ID = ? AND TRAFFIC_GRADE IN ('R','L') AND DETAIL_NO = '1' ORDER BY CASE WHEN BGUBUN=1 THEN STNUMB END DESC, CASE WHEN BGUBUN=0 THEN STNUMB END ";
Map<String, Object> response = new HashMap<>();
Map<String, Object> routeInfo;
try {
routeInfo = troadTemplate.queryForMap(queryGetRouteInfo, routeName);
} catch (EmptyResultDataAccessException e) {
routeInfo = Collections.emptyMap();
}
String quickReplyRouteName = exceptionRoute.contains(routeName)
? routeName.substring(0, routeName.length() - 1)
: routeName;
log.info(quickReplyRouteName);
List<Map<String, Object>> routeQuickReplyInfo;
if (routeName.equals("수도권순환선")) {
routeQuickReplyInfo = new ArrayList<>();
Map<String, Object> firstMap = new HashMap<>();
firstMap.put("ROUTENM", "수도권제1순환선");
firstMap.put("ROUTECD", "");
firstMap.put("GRPID", "");
Map<String, Object> secondMap = new HashMap<>();
secondMap.put("ROUTENM", "수도권제2순환선");
secondMap.put("ROUTECD", "");
secondMap.put("GRPID", "");
routeQuickReplyInfo.add(firstMap);
routeQuickReplyInfo.add(secondMap);
} else {
routeQuickReplyInfo = troadTemplate.queryForList(queryGetRouteQuickReplyInfo,
"%" + quickReplyRouteName + "%");
}
response.put("RouteQuickReplyInfo", routeQuickReplyInfo);
if (!routeInfo.isEmpty()) {
String routeNm = (String) routeInfo.get("ROUTENM");
String routeCd = (String) routeInfo.get("ROUTECD");
String grpId = (String) routeInfo.get("GRPID");
log.info("{} 혼잡정보 조회", routeNm);
log.info("노선코드 : {}, 그룹 식별자 : {}", routeCd, grpId);
List<Map<String, Object>> routeInfoS = troadTemplate.queryForList(queryGetRouteCongestionInfoS, grpId,
routeCd, grpId);
List<Map<String, Object>> routeInfoE = troadTemplate.queryForList(queryGetRouteCongestionInfoE, grpId,
routeCd, grpId);
response.put("status", true);
response.put("statusMsg", "정상응답");
response.put("RouteInfo", routeInfo);
response.put("RouteInfoS", routeInfoS);
response.put("RouteInfoE", routeInfoE);
} else {
response.put("status", false);
response.put("statusMsg", "유효하지 않은 노선입니다.");
log.error("유효하지 않은 노선입니다. - {}", routeName);
}
return response;
}
/**
* 노선별 교통속보 조회
*/
public Map<String, Object> getBreakingNews(String routeName) {
String queryGetRouteInfo = "SELECT ROUTENM, ROUTECD, GRPID FROM TROAD_APP.V_ROUTE_NEW_NM WHERE ROUTENM = ? GROUP BY ROUTENM, ROUTECD, GRPID";
String queryGetRouteQuickReplyInfo = "SELECT ROUTENM, ROUTECD, GRPID FROM TROAD_APP.V_ROUTE_NEW_NM WHERE ROUTENM LIKE ? GROUP BY ROUTENM, ROUTECD, GRPID";
String queryGetBreakingNewsS = "SELECT ROUTECD, GRPID, BOUND AS BND, Y.CMMN_CD_NM AS ROADNM, CASE WHEN BOUND = 'S' THEN SUBSTR(Y.DETL_CTNT, 1, INSTR(Y.DETL_CTNT, '$')-1) WHEN BOUND = 'E' THEN SUBSTR(Y.DETL_CTNT, INSTR(Y.DETL_CTNT, '$')+1) END BOUNDNM, ROUTESORT, ROADSHORTNO, INCIDENT_ID, '[' || TO_CHAR(TO_DATE(INCIDENT_ID, 'YYYYMMDDHH24MISS'), 'YYYY-MM-DD HH24:MI') || ']' AS INCIDENT_DATE, INCI_TP_ORDER, INCI_TP, MSG, INCIDENT_POS_NAME, OCRN_DSTNC, CNGS_LNGT, LAT, LON, CCTV_ID, CCTV_NM, THUMBIMAGE, HLS_URL, ST_D, EN_D, RANK() OVER( PARTITION BY ROUTESORT, Y.CMMN_CD_NM, BOUND ORDER BY ROUTESORT ASC, DECODE(INCI_TP_ORDER, '00','1', '04', '2', '03', '3', '4') ASC, ST_D DESC, INCIDENT_ID DESC ) AS STNUMB , RANK() OVER( PARTITION BY ROUTESORT, Y.CMMN_CD_NM, BOUND ORDER BY ROUTESORT ASC, DECODE(INCI_TP_ORDER, '00','1', '04', '2', '03', '3', '4') DESC, ST_D ASC, INCIDENT_ID ASC ) AS EDNUMB , ' ' AS VVALUE01, ' ' AS VVALUE02, ' ' AS VVALUE03, ' ' AS VVALUE04, ' ' AS VVALUE05 FROM V_ACDT_BRNWS_VER001 X LEFT OUTER JOIN TLISDEV.T_TRMB_CD_INFO01M1 Y ON ( X.ROUTECD || X.GRPID = Y.CMMN_CD AND Y.HGRN_CD_ID = 'COM064' AND Y.USE_XN = 'Y' ) WHERE 1=1 AND ROUTECD = ? AND ROADNM LIKE ? AND BOUND = 'S' AND INCI_TP_ORDER IN ('00','04','03','01')";
String queryGetBreakingNewsE = "SELECT ROUTECD, GRPID, BOUND AS BND, Y.CMMN_CD_NM AS ROADNM, CASE WHEN BOUND = 'S' THEN SUBSTR(Y.DETL_CTNT, 1, INSTR(Y.DETL_CTNT, '$')-1) WHEN BOUND = 'E' THEN SUBSTR(Y.DETL_CTNT, INSTR(Y.DETL_CTNT, '$')+1) END BOUNDNM, ROUTESORT, ROADSHORTNO, INCIDENT_ID, '[' || TO_CHAR(TO_DATE(INCIDENT_ID, 'YYYYMMDDHH24MISS'), 'YYYY-MM-DD HH24:MI') || ']' AS INCIDENT_DATE, INCI_TP_ORDER, INCI_TP, MSG, INCIDENT_POS_NAME, OCRN_DSTNC, CNGS_LNGT, LAT, LON, CCTV_ID, CCTV_NM, THUMBIMAGE, HLS_URL, ST_D, EN_D, RANK() OVER( PARTITION BY ROUTESORT, Y.CMMN_CD_NM, BOUND ORDER BY ROUTESORT ASC, DECODE(INCI_TP_ORDER, '00','1', '04', '2', '03', '3', '4') ASC, ST_D DESC, INCIDENT_ID DESC ) AS STNUMB , RANK() OVER( PARTITION BY ROUTESORT, Y.CMMN_CD_NM, BOUND ORDER BY ROUTESORT ASC, DECODE(INCI_TP_ORDER, '00','1', '04', '2', '03', '3', '4') DESC, ST_D ASC, INCIDENT_ID ASC ) AS EDNUMB , ' ' AS VVALUE01, ' ' AS VVALUE02, ' ' AS VVALUE03, ' ' AS VVALUE04, ' ' AS VVALUE05 FROM V_ACDT_BRNWS_VER001 X LEFT OUTER JOIN TLISDEV.T_TRMB_CD_INFO01M1 Y ON ( X.ROUTECD || X.GRPID = Y.CMMN_CD AND Y.HGRN_CD_ID = 'COM064' AND Y.USE_XN = 'Y' ) WHERE 1=1 AND ROUTECD = ? AND ROADNM LIKE ? AND BOUND = 'E' AND INCI_TP_ORDER IN ('00','04','03','01')";
Map<String, Object> response = new HashMap<>();
Map<String, Object> routeInfo;
try {
routeInfo = troadTemplate.queryForMap(queryGetRouteInfo, routeName);
} catch (EmptyResultDataAccessException e) {
routeInfo = Collections.emptyMap();
}
String quickReplyRouteName = exceptionRoute.contains(routeName)
? routeName.substring(0, routeName.length() - 1)
: routeName;
log.info(quickReplyRouteName);
List<Map<String, Object>> routeQuickReplyInfo;
if (routeName.equals("수도권순환선")) {
routeQuickReplyInfo = new ArrayList<>();
Map<String, Object> firstMap = new HashMap<>();
firstMap.put("ROUTENM", "수도권제1순환선");
firstMap.put("ROUTECD", "");
firstMap.put("GRPID", "");
Map<String, Object> secondMap = new HashMap<>();
secondMap.put("ROUTENM", "수도권제2순환선");
secondMap.put("ROUTECD", "");
secondMap.put("GRPID", "");
routeQuickReplyInfo.add(firstMap);
routeQuickReplyInfo.add(secondMap);
} else {
routeQuickReplyInfo = troadTemplate.queryForList(queryGetRouteQuickReplyInfo,
"%" + quickReplyRouteName + "%");
}
response.put("RouteQuickReplyInfo", routeQuickReplyInfo);
if (!routeInfo.isEmpty()) {
String routeNm = (String) routeInfo.get("ROUTENM");
String routeCd = (String) routeInfo.get("ROUTECD");
String grpId = (String) routeInfo.get("GRPID");
log.info("{} 교통속보 조회", routeNm);
log.info("노선코드 : {}, 그룹 식별자 : {}", routeCd, grpId);
List<Map<String, Object>> routeBreakingNewsS = troadTemplate.queryForList(queryGetBreakingNewsS, routeCd,
"%" + routeNm + "%");
List<Map<String, Object>> routeBreakingNewsE = troadTemplate.queryForList(queryGetBreakingNewsE, routeCd,
"%" + routeNm + "%");
response.put("status", true);
response.put("statusMsg", "정상응답");
response.put("RouteInfo", routeInfo);
response.put("RouteBreakingNewsS", routeBreakingNewsS);
response.put("RouteBreakingNewsE", routeBreakingNewsE);
} else {
response.put("status", false);
response.put("statusMsg", "유효하지 않은 노선입니다.");
log.error("유효하지 않은 노선입니다. - {}", routeName);
}
return response;
}
/**
* 시터스 엔진 소켓 통신
*/
private String extractRouteInfo(String srcGrs, String dstGrs) throws IOException {
String query = "CoordType=WGS84GEO,WGS84GEO&StartPoint=" + srcGrs
+ "&EndPoint=" + dstGrs + "&MidPoint=" + ""
+ "&Result=" + "" + "&SearchOption=12,0,1\n";
InetSocketAddress ipep = new InetSocketAddress("172.16.162.17", 8088);
Socket client = new Socket();
client.connect(ipep);
StringBuffer sb = new StringBuffer();
try (OutputStream sender = client.getOutputStream();
InputStream receiver = client.getInputStream()) {
byte[] data = query.getBytes("UTF-8");
sender.write(data, 0, data.length);
data = new byte[1024000];
int i;
while ((i = receiver.read(data)) != -1) {
sb.append(new String(data, 0, i, "UTF-8"));
if (sb.toString().endsWith("\n.\n\n"))
break;
}
} catch (Throwable e) {
e.printStackTrace();
}
String result = sb.toString();
if (result.endsWith("\n.\n\n")) {
result = result.replace("\n.\n\n", "");
}
client.close();
return result;
}
}
@@ -0,0 +1,72 @@
# =============================================================================
# chatbotApi (WAS) — 172.16.180.130
# 동일 서버: exAiChatBot :28012, chatbotAdmin :8087, Qdrant, MongoDB
# kakaoChatbotSkill: 별도 웹 서버 (SKILL_INTERNAL_BASE_URL 로 지정)
# LLM 게이트웨이: 172.16.163.96
# =============================================================================
server:
port: 8086
http2:
enabled: true
spring:
datasource:
bpstl:
driver-class-name: oracle.jdbc.OracleDriver
url: jdbc:oracle:thin:@localhost:1521/orcl
username: OPR_MAIN
password: test
# url: jdbc:oracle:thin:@172.16.181.22:1681/BPSTL
# username: stl_app
# password: "wjdtksdpdlvlvl!20"
troad:
driver-class-name: oracle.jdbc.OracleDriver
url: jdbc:oracle:thin:@localhost:1521/orcl2
username: OPR_MAIN
password: test
# url: jdbc:oracle:thin:@172.16.163.76:1522/TROAD
# username: TROAD_APP
# password: troad_app!2017
restarea:
driver-class-name: oracle.jdbc.OracleDriver
url: jdbc:oracle:thin:@localhost:1521/orcl
username: OPR_MAIN
password: test
data:
mongodb:
uri: ${MONGODB_URI:mongodb://exDev:ehfhrhdtk!!@127.0.0.1:27017/chatbot?authSource=chatbot&heartbeatFrequencyMS=30000&minPoolSize=3&maxIdleTimeMS=1800000}
# uri: mongodb://exDev:ehfhrhdtk!!@exfareqc.ex.co.kr:27017/chatbot?heartbeatFrequencyMS=30000&minPoolSize=3&maxIdleTimeMS=1800000
restTemplate:
factory:
readTimeout: 5000
connectTimeout: 3000
httpClient:
maxConnTotal: 100
maxConnPerRoute: 5
# WAS → RAG (legacy /llm-agent, /intent/analyze) — RAG는 동일 서버 localhost
rag:
server:
host: ${RAG_SERVER_HOST:127.0.0.1}
port: ${RAG_SERVER_PORT:28012}
ask-endpoint: /ask
health-endpoint: /health
intent-analyze-endpoint: /intent/analyze
connect-timeout: 5000
read-timeout: 30000
# Agent tool API (모드 분기는 Skill에서만 — WAS는 tool executor)
agent:
tool:
internal-api-key: ${INTERNAL_TOOL_API_KEY:}
execute-timeout-ms: ${TOOL_EXECUTE_TIMEOUT_MS:30000}
# WAS → kakaoChatbotSkill (WEB tool internal) — 웹 서버 IP/호스트명 설정
kakao-skill:
base-url: ${SKILL_INTERNAL_BASE_URL:http://128.200.100.61:8083}
data-portal-tool-endpoint: /internal/tools/data-portal
internal-api-key: ${INTERNAL_TOOL_API_KEY:}
connect-timeout: 5000
read-timeout: 30000
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,245 @@
{
"version": 1,
"intents": {
"FARE_SEARCH": {
"description": "출발 IC와 도착 IC 사이 통행요금 조회",
"fetchOwner": "WAS",
"uiType": "FARE_CARD",
"requiredParams": [
{
"name": "fromIc",
"description": "출발 IC명. IC, 영업소, 톨게이트, 요금소 같은 접미사와 공백은 제거한 짧은 한글명으로 추출합니다. 사용자가 '여기', '현재 위치'처럼 말하면 실제 IC명으로 보지 않습니다.",
"examples": ["판교", "서울", "대전"]
},
{
"name": "toIc",
"description": "도착 IC명. IC, 영업소, 톨게이트, 요금소 같은 접미사와 공백은 제거한 짧은 한글명으로 추출합니다.",
"examples": ["신갈", "부산", "광주"]
}
],
"optionalParams": [],
"clarification": {
"default": "통행요금 조회를 위해 출발 IC와 도착 IC를 알려주세요. 예: 판교에서 신갈까지",
"missing": {
"fromIc": "출발 IC를 알려주세요. 예: 판교에서 신갈까지의 통행요금",
"toIc": "도착 IC를 알려주세요. 예: 판교에서 신갈까지의 통행요금"
}
}
},
"FARE_UNPAID": {
"description": "차량의 미납 통행료를 실제로 조회하려는 의도. 전체 차량번호가 있으면 carNo로 추출하고, 조회 의사는 있지만 차량번호가 없으면 carNo를 null로 둡니다. 미납 조회 방법, 납부 방법, 조회 가능 경로만 묻는 안내성 질문은 이 intent가 아니라 일반 RAG로 처리합니다.",
"fetchOwner": "WAS",
"uiType": "TEXT_ONLY",
"requiredParams": [
{
"name": "carNo",
"description": "조회할 전체 차량번호입니다. 공백이나 하이픈은 제거해서 추출합니다. 끝자리만 있는 부분 차량번호는 사용하지 않습니다. 사용자가 실제 조회 의사를 말했지만 차량번호를 말하지 않으면 null로 둡니다.",
"examples": ["12가3456", "123가4567"]
}
],
"optionalParams": [],
"minConfidence": 0.85,
"clarification": {
"default": "챗봇에서 미납 통행료 조회가 가능합니다. 조회하려면 전체 차량번호를 입력해 주세요. 예: 12가3456 미납 조회",
"missing": {
"carNo": "챗봇에서 미납 통행료 조회가 가능합니다. 조회하려면 전체 차량번호를 입력해 주세요. 예: 12가3456 미납 조회"
}
}
},
"FARE_REFUND": {
"description": "차량번호 기반 환불 조회",
"fetchOwner": "WAS",
"uiType": "TEXT_ONLY",
"requiredParams": [
{
"name": "carNo",
"description": "환불 내역을 조회할 전체 차량번호입니다. 공백이나 하이픈은 제거해서 추출합니다. 끝자리만 있는 부분 차량번호는 사용하지 않습니다.",
"examples": ["12가3456", "123가4567"]
}
],
"optionalParams": [],
"minConfidence": 0.85,
"clarification": {
"default": "환불 조회를 위해 차량번호를 알려주세요.",
"missing": {
"carNo": "조회할 차량번호를 알려주세요."
}
}
},
"IC_TEL": {
"description": "IC 또는 영업소 전화번호 조회",
"fetchOwner": "WAS",
"uiType": "PHONE_CARD",
"requiredParams": [
{
"name": "icName",
"description": "전화번호를 확인할 IC명 또는 영업소명입니다. IC, 영업소, 톨게이트, 요금소 같은 접미사와 공백은 제거한 짧은 한글명으로 추출합니다.",
"examples": ["판교", "서울", "신갈"]
}
],
"optionalParams": [],
"clarification": {
"default": "전화번호를 확인할 IC 또는 영업소명을 알려주세요.",
"missing": {
"icName": "전화번호를 확인할 IC 또는 영업소명을 알려주세요."
}
}
},
"GASSTATION": {
"description": "휴게소 주유소 유가/운영 정보 조회",
"fetchOwner": "WEB",
"uiType": "GASSTATION_CARD",
"requiredParams": [
{
"name": "restAreaName",
"description": "주유소 정보를 조회할 휴게소명입니다. 휴게소/주유소 접미사, 방향 표현(서울방향, 부산방향, 익산방향, 포항방향 등), 공백은 제외하고 짧은 한글명만 추출합니다.",
"examples": ["죽전", "망향", "안성", "영천"]
}
],
"optionalParams": [
{
"name": "restAreaDirection",
"description": "사용자가 명시한 휴게소 방향 또는 상행/하행 구분입니다. '영천 주유소 익산방향'처럼 휴게소명 뒤에 붙은 방향도 반드시 분리해서 추출합니다. 방향을 말하지 않았으면 null로 둡니다.",
"examples": ["서울방향", "부산방향", "익산방향", "포항방향", "상행", "하행"]
}
],
"clarification": {
"default": "조회할 휴게소명을 알려주세요.",
"missing": {
"restAreaName": "주유소 정보를 확인할 휴게소명을 알려주세요."
}
}
},
"BRAND_SHOP": {
"description": "휴게소 브랜드 매장 조회",
"fetchOwner": "WEB",
"uiType": "BRAND_SHOP_CARD",
"requiredParams": [
{
"name": "restAreaName",
"description": "브랜드 매장을 조회할 휴게소명입니다. 휴게소 접미사와 공백은 제거한 짧은 한글명으로 추출합니다.",
"examples": ["죽전", "망향", "안성"]
}
],
"optionalParams": [
{
"name": "brand",
"description": "찾고 싶은 브랜드 또는 매장명입니다.",
"examples": ["스타벅스", "탐앤탐스", "롯데리아"]
},
{
"name": "startIndex",
"description": "목록 조회 시작 위치입니다. 사용자가 직접 말하지 않으면 추출하지 않습니다.",
"examples": ["1", "10"]
}
],
"clarification": {
"default": "조회할 휴게소명을 알려주세요.",
"missing": {
"restAreaName": "브랜드 매장을 확인할 휴게소명을 알려주세요."
}
}
},
"REST_AREA_FOOD_LIST": {
"description": "휴게소 음식/메뉴 목록 조회",
"fetchOwner": "WEB",
"uiType": "FOOD_LIST_CARD",
"requiredParams": [
{
"name": "restAreaName",
"description": "음식/메뉴 목록을 조회할 휴게소명입니다. 휴게소 접미사와 공백은 제거한 짧은 한글명으로 추출합니다.",
"examples": ["죽전", "망향", "안성"]
}
],
"optionalParams": [],
"clarification": {
"default": "조회할 휴게소명을 알려주세요.",
"missing": {
"restAreaName": "음식 정보를 확인할 휴게소명을 알려주세요."
}
}
},
"REST_AREA_PRICE": {
"description": "휴게소 음식 가격 조회",
"fetchOwner": "WAS",
"uiType": "PRICE_CARD",
"requiredParams": [
{
"name": "restAreaName",
"description": "음식 가격을 확인할 휴게소명입니다. 휴게소 접미사와 공백은 제거한 짧은 한글명으로 추출합니다.",
"examples": ["죽전", "망향", "안성"]
}
],
"optionalParams": [],
"clarification": {
"default": "음식 가격을 확인할 휴게소명을 알려주세요.",
"missing": {
"restAreaName": "음식 가격을 확인할 휴게소명을 알려주세요."
}
}
},
"TROAD_SEARCH_ROUTE": {
"description": "출발 IC와 도착 IC 기준 교통 경로 조회",
"fetchOwner": "WAS",
"uiType": "TEXT_ONLY",
"requiredParams": [
{
"name": "fromIc",
"description": "교통 경로 조회의 출발 IC명입니다. IC, 영업소, 톨게이트, 요금소 같은 접미사와 공백은 제거한 짧은 한글명으로 추출합니다.",
"examples": ["판교", "서울", "대전"]
},
{
"name": "toIc",
"description": "교통 경로 조회의 도착 IC명입니다. IC, 영업소, 톨게이트, 요금소 같은 접미사와 공백은 제거한 짧은 한글명으로 추출합니다.",
"examples": ["신갈", "부산", "광주"]
}
],
"optionalParams": [],
"clarification": {
"default": "교통 경로 조회를 위해 출발 IC와 도착 IC를 알려주세요.",
"missing": {
"fromIc": "출발 IC를 알려주세요.",
"toIc": "도착 IC를 알려주세요."
}
}
},
"TROAD_CONGESTION": {
"description": "노선 정체 조회",
"fetchOwner": "WAS",
"uiType": "TEXT_ONLY",
"requiredParams": [
{
"name": "routeName",
"description": "정체 정보를 조회할 고속도로 노선명입니다.",
"examples": ["경부선", "영동선", "서해안선"]
}
],
"optionalParams": [],
"clarification": {
"default": "조회할 노선명을 알려주세요. 예: 경부선, 영동선",
"missing": {
"routeName": "조회할 노선명을 알려주세요. 예: 경부선, 영동선"
}
}
},
"TROAD_BREAKING_NEWS": {
"description": "노선 사고/공사/돌발정보 조회",
"fetchOwner": "WAS",
"uiType": "TEXT_ONLY",
"requiredParams": [
{
"name": "routeName",
"description": "사고/공사/돌발정보를 조회할 고속도로 노선명입니다.",
"examples": ["경부선", "영동선", "서해안선"]
}
],
"optionalParams": [],
"clarification": {
"default": "조회할 노선명을 알려주세요. 예: 경부선, 영동선",
"missing": {
"routeName": "조회할 노선명을 알려주세요. 예: 경부선, 영동선"
}
}
}
}
}
@@ -0,0 +1,13 @@
package com.ex.chatbotapi;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class ChatbotapiApplicationTests {
@Test
void contextLoads() {
}
}
@@ -0,0 +1,51 @@
package com.ex.chatbotapi.domain.agent.service;
import com.ex.chatbotapi.domain.agent.dto.IntentValidationResult;
import com.ex.chatbotapi.domain.agent.intent.IntentDefinitionRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class IntentParamValidatorTest {
private IntentParamValidator validator;
@BeforeEach
void setUp() {
IntentDefinitionRegistry registry = new IntentDefinitionRegistry();
registry.load();
validator = new IntentParamValidator(registry);
}
@Test
void fareSearchRequiresRealDepartureAndDestination() {
Map<String, String> params = new HashMap<>();
params.put("fromIc", "여기");
params.put("toIc", "신갈");
IntentValidationResult result = validator.validate("FARE_SEARCH", params);
assertThat(result.isValid()).isFalse();
assertThat(result.getMissingParams()).containsExactly("fromIc");
assertThat(result.getCleanedParams()).containsEntry("toIc", "신갈");
assertThat(result.getCleanedParams()).doesNotContainKey("fromIc");
}
@Test
void fareSearchPassesWithBothIcParams() {
Map<String, String> params = new HashMap<>();
params.put("fromIc", "판교");
params.put("toIc", "신갈");
IntentValidationResult result = validator.validate("FARE_SEARCH", params);
assertThat(result.isValid()).isTrue();
assertThat(result.getMissingParams()).isEmpty();
assertThat(result.getCleanedParams()).containsEntry("fromIc", "판교");
assertThat(result.getCleanedParams()).containsEntry("toIc", "신갈");
}
}
@@ -0,0 +1,70 @@
package com.ex.chatbotapi.domain.agent.service;
import com.ex.chatbotapi.domain.agent.intent.FetchOwner;
import com.ex.chatbotapi.domain.agent.intent.IntentDefinition;
import com.ex.chatbotapi.domain.agent.intent.IntentDefinitionRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ToolDefinitionServiceTest {
@Mock
private IntentDefinitionRegistry intentDefinitionRegistry;
private ToolDefinitionService toolDefinitionService;
@BeforeEach
void setUp() {
toolDefinitionService = new ToolDefinitionService(intentDefinitionRegistry);
}
@Test
void listToolDefinitions_excludesNoneFetchOwner() {
IntentDefinition fare = definition("FARE_SEARCH", FetchOwner.WAS, "FARE_CARD");
IntentDefinition ragOnly = definition("PURE_RAG", FetchOwner.NONE, "TEXT_ONLY");
when(intentDefinitionRegistry.all()).thenReturn(Map.of(
"FARE_SEARCH", fare,
"PURE_RAG", ragOnly
));
List<Map<String, Object>> tools = toolDefinitionService.listToolDefinitions();
assertThat(tools).hasSize(1);
@SuppressWarnings("unchecked")
Map<String, Object> function = (Map<String, Object>) tools.get(0).get("function");
assertThat(function.get("name")).isEqualTo("FARE_SEARCH");
assertThat(function.get("description").toString()).contains("fetchOwner=WAS");
}
private IntentDefinition definition(String name, FetchOwner owner, String uiType) {
IntentDefinition definition = new IntentDefinition();
definition.setIntentType(name);
definition.setDescription("desc for " + name);
definition.setFetchOwner(owner);
definition.setUiType(uiType);
IntentDefinition.ParamDefinition fromIc = new IntentDefinition.ParamDefinition();
fromIc.setName("fromIc");
fromIc.setDescription("출발 IC");
fromIc.setExamples(List.of("판교"));
IntentDefinition.ParamDefinition toIc = new IntentDefinition.ParamDefinition();
toIc.setName("toIc");
toIc.setDescription("도착 IC");
definition.setRequiredParamDefinitions(List.of(fromIc, toIc));
definition.setRequiredParams(List.of("fromIc", "toIc"));
return definition;
}
}
@@ -0,0 +1,136 @@
package com.ex.chatbotapi.domain.agent.service;
import com.ex.chatbotapi.domain.agent.client.KakaoSkillToolClient;
import com.ex.chatbotapi.domain.agent.dto.ToolExecuteRequest;
import com.ex.chatbotapi.domain.agent.dto.ToolExecuteResponse;
import com.ex.chatbotapi.domain.agent.dto.ToolParamPipelineResult;
import com.ex.chatbotapi.domain.agent.handler.IntentHandler;
import com.ex.chatbotapi.domain.agent.intent.FetchOwner;
import com.ex.chatbotapi.domain.agent.intent.IntentDefinition;
import com.ex.chatbotapi.domain.agent.intent.IntentDefinitionRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ToolExecutionServiceTest {
@Mock
private IntentDefinitionRegistry intentDefinitionRegistry;
@Mock
private IntentHandler fareSearchHandler;
@Mock
private KakaoSkillToolClient kakaoSkillToolClient;
@Mock
private ToolParamPipeline toolParamPipeline;
private ToolExecutionService toolExecutionService;
@BeforeEach
void setUp() {
when(fareSearchHandler.getIntentType()).thenReturn("FARE_SEARCH");
toolExecutionService = new ToolExecutionService(
intentDefinitionRegistry,
List.of(fareSearchHandler),
List.of(),
kakaoSkillToolClient,
toolParamPipeline
);
}
@Test
void execute_returnsClarifyWhenPipelineFails() {
IntentDefinition definition = new IntentDefinition();
definition.setIntentType("FARE_SEARCH");
definition.setFetchOwner(FetchOwner.WAS);
definition.setUiType("FARE_CARD");
when(intentDefinitionRegistry.get("FARE_SEARCH")).thenReturn(definition);
when(toolParamPipeline.process(eq("FARE_SEARCH"), any()))
.thenReturn(ToolParamPipelineResult.clarify(
Map.of("toIc", "신갈"),
"출발 IC를 알려주세요.",
List.of("fromIc")
));
ToolExecuteResponse response = toolExecutionService.execute(
ToolExecuteRequest.builder()
.toolName("FARE_SEARCH")
.arguments(Map.of("toIc", "신갈"))
.build()
);
assertThat(response.isStatus()).isFalse();
assertThat(response.isNeedsClarification()).isTrue();
assertThat(response.getClarificationQuestion()).contains("출발");
assertThat(response.getMissingParams()).contains("fromIc");
}
@Test
void execute_wasHandlerSuccess() {
IntentDefinition definition = new IntentDefinition();
definition.setIntentType("FARE_SEARCH");
definition.setFetchOwner(FetchOwner.WAS);
definition.setUiType("FARE_CARD");
when(intentDefinitionRegistry.get("FARE_SEARCH")).thenReturn(definition);
when(toolParamPipeline.process(eq("FARE_SEARCH"), any()))
.thenReturn(ToolParamPipelineResult.success(Map.of(
"fromIc", "판교",
"toIc", "신갈"
)));
Map<String, Object> domainData = new HashMap<>();
domainData.put("status", true);
domainData.put("statusMsg", "정상응답");
when(fareSearchHandler.fetchDomainData(any())).thenReturn(domainData);
ToolExecuteResponse response = toolExecutionService.execute(
ToolExecuteRequest.builder()
.toolName("FARE_SEARCH")
.arguments(Map.of("fromIc", "판교", "toIc", "신갈"))
.build()
);
assertThat(response.isStatus()).isTrue();
assertThat(response.getIntentType()).isEqualTo("FARE_SEARCH");
assertThat(response.getFetchOwner()).isEqualTo("WAS");
assertThat(response.getDomainData()).containsEntry("status", true);
}
@Test
void execute_webFetchViaSkillClient() {
IntentDefinition definition = new IntentDefinition();
definition.setIntentType("GASSTATION");
definition.setFetchOwner(FetchOwner.WEB);
definition.setUiType("GASSTATION_CARD");
when(intentDefinitionRegistry.get("GASSTATION")).thenReturn(definition);
when(toolParamPipeline.process(eq("GASSTATION"), any()))
.thenReturn(ToolParamPipelineResult.success(Map.of("restAreaName", "죽전")));
Map<String, Object> domainData = new HashMap<>();
domainData.put("status", true);
when(kakaoSkillToolClient.fetchDataPortal(eq("GASSTATION"), any(), any()))
.thenReturn(domainData);
ToolExecuteResponse response = toolExecutionService.execute(
ToolExecuteRequest.builder()
.toolName("GASSTATION")
.arguments(Map.of("restAreaName", "죽전"))
.userInput("죽전 주유소")
.build()
);
assertThat(response.isStatus()).isTrue();
assertThat(response.getFetchOwner()).isEqualTo("WEB");
}
}
@@ -0,0 +1,48 @@
package com.ex.chatbotapi.domain.fare.service;
import org.bson.Document;
import org.junit.jupiter.api.Test;
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 FareAmountHelperTest {
@Test
void applyExactFareAmounts_buildsWebBreakdown() {
Document trsDoc = new Document();
trsDoc.put("sum", Arrays.asList(3200, 3500, 4000, 4500, 5000, 2800));
Document amt = new Document();
amt.put("00", Arrays.asList(3000, 3300, 3800, 4300, 4800, 2700));
trsDoc.put("amt", amt);
Map<String, Object> fareData = new HashMap<>();
fareData.put("type", "exact");
fareData.put("fromIc", "판교");
fareData.put("toIc", "신갈");
FareAmountHelper.applyExactFareAmounts(fareData, trsDoc);
assertThat(fareData.get("fare1")).isEqualTo(3200);
assertThat(fareData.get("fare1Small")).isEqualTo(2800);
@SuppressWarnings("unchecked")
Map<String, Object> breakdown = (Map<String, Object>) fareData.get("webBreakdown");
assertThat(breakdown).isNotNull();
assertThat(breakdown.get("fromIc")).isEqualTo("판교");
assertThat(breakdown.get("toIc")).isEqualTo("신갈");
assertThat(breakdown.get("totalFare")).isEqualTo(3200);
assertThat(breakdown.get("kecFare")).isEqualTo(3000);
assertThat(breakdown.get("privateFare")).isEqualTo(200);
@SuppressWarnings("unchecked")
List<Map<String, Object>> vehicles = (List<Map<String, Object>>) breakdown.get("vehicles");
assertThat(vehicles).hasSize(6);
assertThat(vehicles.get(0).get("typeLabel")).isEqualTo("1종");
assertThat(vehicles.get(5).get("compact")).isEqualTo(true);
}
}

Some files were not shown because too many files have changed in this diff Show More