commit 4b86b2a660f01fa50d31c0edf10901e5877196e9 Author: Macbook Date: Tue Jul 21 22:57:30 2026 +0900 Agent 2.0 exdev 서버 배포 스택 - server-dev start/stop/deploy 및 Gitea push 자동 배포 - local-dev 로컬 개발 환경 Co-authored-by: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a521a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# exChatbot Agent 2.0 +**/build/ +**/.gradle/ +**/node_modules/ +**/.env +.DS_Store +*.log +*.pid +*.zip + +exAiChatBot-chatbot2.0-agent/qdrant_data/ +chatbotApp-chatbot2.0-agent/public/ +chatbotApp-chatbot2.0-agent/public-*/ +local-dev/run/ +local-dev/logs/ +server-dev/run/ +server-dev/logs/ diff --git a/chatbotApi-chatbot2.0-agent.zip b/chatbotApi-chatbot2.0-agent.zip new file mode 100644 index 0000000..2ad50d1 Binary files /dev/null and b/chatbotApi-chatbot2.0-agent.zip differ diff --git a/chatbotApi-chatbot2.0-agent/.gitignore b/chatbotApi-chatbot2.0-agent/.gitignore new file mode 100644 index 0000000..9fd0d94 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/.gitignore @@ -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/ diff --git a/chatbotApi-chatbot2.0-agent/RAG_API_IMPLEMENTATION.md b/chatbotApi-chatbot2.0-agent/RAG_API_IMPLEMENTATION.md new file mode 100644 index 0000000..d8e39e9 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/RAG_API_IMPLEMENTATION.md @@ -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 스펙에 맞춰 수정되었습니다. + +테스트 후 문제가 있으면 알려주세요! 😊 + diff --git a/chatbotApi-chatbot2.0-agent/README.md b/chatbotApi-chatbot2.0-agent/README.md new file mode 100644 index 0000000..539ac39 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/README.md @@ -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별로 정렬 + } + } +]); +``` \ No newline at end of file diff --git a/chatbotApi-chatbot2.0-agent/build.gradle b/chatbotApi-chatbot2.0-agent/build.gradle new file mode 100644 index 0000000..4c2269e --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/build.gradle @@ -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() +} diff --git a/chatbotApi-chatbot2.0-agent/env.example b/chatbotApi-chatbot2.0-agent/env.example new file mode 100644 index 0000000..d5f0717 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/env.example @@ -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 diff --git a/chatbotApi-chatbot2.0-agent/gradle.properties b/chatbotApi-chatbot2.0-agent/gradle.properties new file mode 100644 index 0000000..6d66cc0 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/gradle.properties @@ -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 + diff --git a/chatbotApi-chatbot2.0-agent/gradle/wrapper/gradle-wrapper.jar b/chatbotApi-chatbot2.0-agent/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..033e24c Binary files /dev/null and b/chatbotApi-chatbot2.0-agent/gradle/wrapper/gradle-wrapper.jar differ diff --git a/chatbotApi-chatbot2.0-agent/gradle/wrapper/gradle-wrapper.properties b/chatbotApi-chatbot2.0-agent/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..9355b41 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/chatbotApi-chatbot2.0-agent/gradlew b/chatbotApi-chatbot2.0-agent/gradlew new file mode 100755 index 0000000..fcb6fca --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/gradlew @@ -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" "$@" diff --git a/chatbotApi-chatbot2.0-agent/gradlew.bat b/chatbotApi-chatbot2.0-agent/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/gradlew.bat @@ -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 diff --git a/chatbotApi-chatbot2.0-agent/lombok.config b/chatbotApi-chatbot2.0-agent/lombok.config new file mode 100644 index 0000000..8ab6efb --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/lombok.config @@ -0,0 +1,2 @@ +config.stopBubbling = true +lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier diff --git a/chatbotApi-chatbot2.0-agent/settings.gradle b/chatbotApi-chatbot2.0-agent/settings.gradle new file mode 100644 index 0000000..e099cda --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'chatbotApi' diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/ChatbotapiApplication.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/ChatbotapiApplication.java new file mode 100644 index 0000000..8e4e1eb --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/ChatbotapiApplication.java @@ -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); + } + +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/config/AgentProperties.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/config/AgentProperties.java new file mode 100644 index 0000000..d4853dc --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/config/AgentProperties.java @@ -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; + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/config/KakaoSkillConfig.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/config/KakaoSkillConfig.java new file mode 100644 index 0000000..e0e97c9 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/config/KakaoSkillConfig.java @@ -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; + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/config/MongoDbSettings.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/config/MongoDbSettings.java new file mode 100644 index 0000000..208fe55 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/config/MongoDbSettings.java @@ -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); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/config/OracleDbConfig.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/config/OracleDbConfig.java new file mode 100644 index 0000000..8e2635d --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/config/OracleDbConfig.java @@ -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); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/config/RagConfig.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/config/RagConfig.java new file mode 100644 index 0000000..b984264 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/config/RagConfig.java @@ -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); + } +} + diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/config/RestfulConfig.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/config/RestfulConfig.java new file mode 100644 index 0000000..d3a23d4 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/config/RestfulConfig.java @@ -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; + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/api/AgentApiController.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/api/AgentApiController.java new file mode 100644 index 0000000..d5bafa4 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/api/AgentApiController.java @@ -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 processQuery(@RequestBody AgentQueryRequest request) { + log.info("Agent API 호출 - question: {}, intentType: {}", + request.getQuestion(), request.getIntentType()); + return ResponseEntity.ok(agentOrchestratorService.processUserQuery(request)); + } + + /** + * 테스트용 GET 엔드포인트 + */ + @GetMapping + public ResponseEntity 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 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)); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/api/AgentToolController.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/api/AgentToolController.java new file mode 100644 index 0000000..1b3e3de --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/api/AgentToolController.java @@ -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 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 errorBody = new LinkedHashMap<>(); + errorBody.put("status", false); + errorBody.put("statusMsg", "invalid or missing X-Internal-Tool-Key"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorBody); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/api/DomainOnlyApiController.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/api/DomainOnlyApiController.java new file mode 100644 index 0000000..88bedf7 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/api/DomainOnlyApiController.java @@ -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 fetchDomainData(@RequestBody DomainOnlyRequest request) { + log.info("DomainOnly API 호출 - intentType: {}", request.getIntentType()); + return ResponseEntity.ok(domainOnlyService.fetchDomainData(request)); + } + + /** + * 테스트용 GET 엔드포인트 + */ + @GetMapping + public ResponseEntity 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 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)); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/api/LlmAgentController.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/api/LlmAgentController.java new file mode 100644 index 0000000..71260ce --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/api/LlmAgentController.java @@ -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 process(@RequestBody LlmAgentRequest request) { + log.info("LLM Agent API 호출 - question: {}, botId: {}", request.getQuestion(), request.getBotId()); + return ResponseEntity.ok(llmAgentRouterService.process(request)); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/client/KakaoSkillToolClient.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/client/KakaoSkillToolClient.java new file mode 100644 index 0000000..a24ab7a --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/client/KakaoSkillToolClient.java @@ -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 fetchDataPortal( + String intentType, + Map params, + String userInput + ) { + String url = kakaoSkillConfig.getDataPortalToolUrl(); + Map 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> entity = new HttpEntity<>(body, headers); + ResponseEntity> response = restTemplate.exchange( + url, + HttpMethod.POST, + entity, + new ParameterizedTypeReference>() {} + ); + Map 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 error(String message) { + Map result = new HashMap<>(); + result.put("status", false); + result.put("statusMsg", message); + return result; + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/AgentQueryRequest.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/AgentQueryRequest.java new file mode 100644 index 0000000..33ca424 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/AgentQueryRequest.java @@ -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 params; + + /** 봇 ID (대화 이력 관리용) */ + private String botId; + + /** + * 클라이언트(skill)가 직접 수집한 도메인 데이터. + * 값이 있으면 chatbotApi의 IntentHandler 호출을 생략하고 formatter + RAG만 실행한다. + * (WAS에서 접근 불가한 외부 API를 Web 계층에서 호출하는 경우 사용) + */ + private Map domainData; +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/AgentQueryResponse.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/AgentQueryResponse.java new file mode 100644 index 0000000..10db133 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/AgentQueryResponse.java @@ -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 domainData; + + /** 공식 FAQ 상세 URL 목록 */ + private List faqUrls; + + /** RAG 참고자료 메타데이터 */ + private List> references; + + /** 상태 메시지 (에러 시 오류 내용) */ + private String statusMsg; +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/CarNoParamNormalizationResult.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/CarNoParamNormalizationResult.java new file mode 100644 index 0000000..b95fd36 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/CarNoParamNormalizationResult.java @@ -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 normalizedParams = new HashMap<>(); +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/DomainOnlyRequest.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/DomainOnlyRequest.java new file mode 100644 index 0000000..92e8618 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/DomainOnlyRequest.java @@ -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 params; +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/DomainOnlyResponse.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/DomainOnlyResponse.java new file mode 100644 index 0000000..7bfdfc9 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/DomainOnlyResponse.java @@ -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 domainData; + + /** 상태 메시지 (에러 시 오류 내용) */ + private String statusMsg; +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/IcNameCandidate.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/IcNameCandidate.java new file mode 100644 index 0000000..bc5ccf7 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/IcNameCandidate.java @@ -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; +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/IcParamNormalizationResult.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/IcParamNormalizationResult.java new file mode 100644 index 0000000..667b3ad --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/IcParamNormalizationResult.java @@ -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 normalizedParams = new HashMap<>(); + + @Builder.Default + private List candidates = new ArrayList<>(); +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/IntentValidationResult.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/IntentValidationResult.java new file mode 100644 index 0000000..adb96e4 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/IntentValidationResult.java @@ -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 cleanedParams = new HashMap<>(); + @Builder.Default + private List missingParams = new ArrayList<>(); + private String clarificationQuestion; +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/LlmAgentRequest.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/LlmAgentRequest.java new file mode 100644 index 0000000..27535cf --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/LlmAgentRequest.java @@ -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; +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/LlmAgentResponse.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/LlmAgentResponse.java new file mode 100644 index 0000000..67022c1 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/LlmAgentResponse.java @@ -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 domainData; + private List faqUrls; + private List> references; + private boolean needsClarification; + private String clarificationQuestion; + private String pendingIntentType; + private List missingParams; + private Map params; + private List icCandidates; +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/PendingIntentState.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/PendingIntentState.java new file mode 100644 index 0000000..ee2400b --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/PendingIntentState.java @@ -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 collectedParams = new HashMap<>(); + @Builder.Default + private List missingParams = new ArrayList<>(); + private Instant createdAt; +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/RestAreaParamNormalizationResult.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/RestAreaParamNormalizationResult.java new file mode 100644 index 0000000..1626d3a --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/RestAreaParamNormalizationResult.java @@ -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 normalizedParams = new HashMap<>(); +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/ToolExecuteRequest.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/ToolExecuteRequest.java new file mode 100644 index 0000000..341c7c7 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/ToolExecuteRequest.java @@ -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 arguments = new HashMap<>(); + private String botId; + private String userInput; +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/ToolExecuteResponse.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/ToolExecuteResponse.java new file mode 100644 index 0000000..2daeca4 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/ToolExecuteResponse.java @@ -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 params = new HashMap<>(); + private Map domainData; + private boolean needsClarification; + private String clarificationQuestion; + @Builder.Default + private List missingParams = new ArrayList<>(); + @Builder.Default + private List icCandidates = new ArrayList<>(); +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/ToolParamPipelineResult.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/ToolParamPipelineResult.java new file mode 100644 index 0000000..9c5fb5c --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/dto/ToolParamPipelineResult.java @@ -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 params = new HashMap<>(); + private boolean needsClarification; + private String clarificationQuestion; + @Builder.Default + private List missingParams = new ArrayList<>(); + @Builder.Default + private List icCandidates = new ArrayList<>(); + + public static ToolParamPipelineResult success(Map params) { + return ToolParamPipelineResult.builder() + .valid(true) + .params(params) + .build(); + } + + public static ToolParamPipelineResult clarify( + Map params, + String question, + List missingParams + ) { + return ToolParamPipelineResult.builder() + .valid(false) + .params(params) + .needsClarification(true) + .clarificationQuestion(question) + .missingParams(missingParams != null ? missingParams : new ArrayList<>()) + .build(); + } + + public static ToolParamPipelineResult icAmbiguous( + Map params, + String question, + List candidates + ) { + return ToolParamPipelineResult.builder() + .valid(false) + .params(params) + .needsClarification(true) + .clarificationQuestion(question) + .icCandidates(candidates != null ? candidates : new ArrayList<>()) + .build(); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/formatter/LlmDomainDataFormatter.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/formatter/LlmDomainDataFormatter.java new file mode 100644 index 0000000..ac565d6 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/formatter/LlmDomainDataFormatter.java @@ -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 domainData); +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/handler/IntentHandler.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/handler/IntentHandler.java new file mode 100644 index 0000000..644ed20 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/handler/IntentHandler.java @@ -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 fetchDomainData(Map params); +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/intent/FetchOwner.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/intent/FetchOwner.java new file mode 100644 index 0000000..77392a9 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/intent/FetchOwner.java @@ -0,0 +1,7 @@ +package com.ex.chatbotapi.domain.agent.intent; + +public enum FetchOwner { + WAS, + WEB, + NONE +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/intent/IntentDefinition.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/intent/IntentDefinition.java new file mode 100644 index 0000000..29ed9a7 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/intent/IntentDefinition.java @@ -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 requiredParams = Collections.emptyList(); + private List optionalParams = Collections.emptyList(); + private List requiredParamDefinitions = Collections.emptyList(); + private List 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 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 missing = Collections.emptyMap(); + } + + @Data + public static class ParamDefinition { + private String name; + private String description; + private List examples = Collections.emptyList(); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/intent/IntentDefinitionRegistry.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/intent/IntentDefinitionRegistry.java new file mode 100644 index 0000000..c30cdff --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/intent/IntentDefinitionRegistry.java @@ -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 definitions = new LinkedHashMap<>(); + + @PostConstruct + @SuppressWarnings("unchecked") + public void load() { + try (InputStream inputStream = openIntentDefinitions()) { + Map root = new Yaml().load(inputStream); + Map intents = (Map) root.get("intents"); + definitions.clear(); + + for (Map.Entry entry : intents.entrySet()) { + String intentType = entry.getKey(); + Map raw = (Map) 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 all() { + return Collections.unmodifiableMap(definitions); + } + + public List> toAnalysisDefinitions() { + List> result = new ArrayList<>(); + for (IntentDefinition definition : definitions.values()) { + Map 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> mergeParamDefinitions(IntentDefinition definition) { + List> params = new ArrayList<>(); + appendParamDefinitions(params, definition.getRequiredParamDefinitions(), true); + appendParamDefinitions(params, definition.getOptionalParamDefinitions(), false); + return params; + } + + private void appendParamDefinitions( + List> params, + List definitions, + boolean required + ) { + for (IntentDefinition.ParamDefinition definition : definitions) { + Map 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 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 requiredParamDefinitions = asParamDefinitions(raw.get("requiredParams")); + List 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 raw = (Map) value; + clarification.setDefaultMessage(asString(raw.get("default"))); + + Map missing = new LinkedHashMap<>(); + Object missingObject = raw.get("missing"); + if (missingObject instanceof Map) { + Map rawMissing = (Map) missingObject; + for (Map.Entry 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 asParamDefinitions(Object value) { + if (!(value instanceof List)) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + for (Object item : (List) value) { + IntentDefinition.ParamDefinition paramDefinition = new IntentDefinition.ParamDefinition(); + if (item instanceof Map) { + Map raw = (Map) 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 toParamNames(List paramDefinitions) { + List result = new ArrayList<>(); + for (IntentDefinition.ParamDefinition paramDefinition : paramDefinitions) { + result.add(paramDefinition.getName()); + } + return result; + } + + @SuppressWarnings("unchecked") + private List asStringList(Object value) { + if (!(value instanceof List)) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + for (Object item : (List) value) { + result.add(asString(item)); + } + return result; + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/AgentOrchestratorService.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/AgentOrchestratorService.java new file mode 100644 index 0000000..59837ac --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/AgentOrchestratorService.java @@ -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 handlerMap; + private final Map llmFormatterMap; + + public AgentOrchestratorService(List handlers, + List 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 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 ragResult = ragService.processQuestion(ragRequest); + + return AgentQueryResponse.builder() + .status(true) + .intentType(request.getIntentType()) + .llmAnswer((String) ragResult.get("answer")) + .domainData(domainData) + .faqUrls((List) ragResult.get("faqUrls")) + .references((List>) 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 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 domainData) { + if (domainData == null || intentType == null) { + return; + } + LlmDomainDataFormatter formatter = llmFormatterMap.get(intentType); + if (formatter != null) { + formatter.enrich(domainData); + } + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/CarNoParamNormalizer.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/CarNoParamNormalizer.java new file mode 100644 index 0000000..0f02617 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/CarNoParamNormalizer.java @@ -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 CAR_NO_INTENTS = Arrays.asList( + "FARE_UNPAID", + "FARE_REFUND" + ); + + public CarNoParamNormalizationResult normalize(String intentType, Map params) { + Map 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 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"; + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/DomainOnlyService.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/DomainOnlyService.java new file mode 100644 index 0000000..d18f107 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/DomainOnlyService.java @@ -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 handlerMap; + + public DomainOnlyService(List 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 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(); + } + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/IcParamNormalizer.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/IcParamNormalizer.java new file mode 100644 index 0000000..ce71689 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/IcParamNormalizer.java @@ -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 IC_INTENTS = Arrays.asList( + "FARE_SEARCH", + "TROAD_SEARCH_ROUTE", + "IC_TEL" + ); + + private final FareRepository fareRepository; + + public IcParamNormalizationResult normalize(String intentType, Map params) { + Map 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 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 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 findCandidates(String icName) { + List documents = fareRepository.findIcInfoByNameContains(icName, CANDIDATE_LIMIT); + + Map 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 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; + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/IntentAnalysisParamNormalizer.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/IntentAnalysisParamNormalizer.java new file mode 100644 index 0000000..4f4bb06 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/IntentAnalysisParamNormalizer.java @@ -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<String, String>으로 변환한다. + */ +@Component +public class IntentAnalysisParamNormalizer { + + public Map toStringMap(Map rawParams) { + Map normalized = new HashMap<>(); + if (rawParams == null) { + return normalized; + } + + for (Map.Entry 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; + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/IntentParamValidator.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/IntentParamValidator.java new file mode 100644 index 0000000..da67724 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/IntentParamValidator.java @@ -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 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 params) { + Map cleaned = cleanParams(params); + List missing = new ArrayList<>(); + + IntentDefinition definition = intentDefinitionRegistry.get(intentType); + if (definition == null) { + return IntentValidationResult.builder() + .valid(false) + .cleanedParams(cleaned) + .missingParams(Collections.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 cleanParams(Map params) { + Map cleaned = new HashMap<>(); + if (params == null) { + return cleaned; + } + + for (Map.Entry 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(); + } + +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/LlmAgentRouterService.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/LlmAgentRouterService.java new file mode 100644 index 0000000..633aa64 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/LlmAgentRouterService.java @@ -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()); + } + + 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 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 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 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 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 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) ragResult.get("faqUrls")) + .references((List>) 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 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 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); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/PendingIntentService.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/PendingIntentService.java new file mode 100644 index 0000000..95479dc --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/PendingIntentService.java @@ -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 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()); + } + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/RestAreaParamNormalizer.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/RestAreaParamNormalizer.java new file mode 100644 index 0000000..e09fa5a --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/RestAreaParamNormalizer.java @@ -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 REST_AREA_INTENTS = Arrays.asList( + "GASSTATION", + "BRAND_SHOP", + "REST_AREA_FOOD_LIST", + "REST_AREA_PRICE" + ); + + public RestAreaParamNormalizationResult normalize(String intentType, Map params) { + Map 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(); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/ToolDefinitionService.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/ToolDefinitionService.java new file mode 100644 index 0000000..61b5278 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/ToolDefinitionService.java @@ -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> listToolDefinitions() { + List> tools = new ArrayList<>(); + for (IntentDefinition definition : intentDefinitionRegistry.all().values()) { + if (definition.getFetchOwner() == FetchOwner.NONE) { + continue; + } + tools.add(toOpenAiTool(definition)); + } + return tools; + } + + private Map toOpenAiTool(IntentDefinition definition) { + Map properties = new LinkedHashMap<>(); + List required = new ArrayList<>(); + + appendParamProperties(properties, required, definition.getRequiredParamDefinitions(), true); + appendParamProperties(properties, required, definition.getOptionalParamDefinitions(), false); + + Map parameters = new LinkedHashMap<>(); + parameters.put("type", "object"); + parameters.put("properties", properties); + parameters.put("required", required); + + Map function = new LinkedHashMap<>(); + function.put("name", definition.getIntentType()); + function.put( + "description", + definition.getDescription() + + " [fetchOwner=" + + definition.getFetchOwner() + + ", uiType=" + + definition.getUiType() + + "]" + ); + function.put("parameters", parameters); + + Map tool = new LinkedHashMap<>(); + tool.put("type", "function"); + tool.put("function", function); + return tool; + } + + private void appendParamProperties( + Map properties, + List required, + List params, + boolean markRequired + ) { + if (params == null) { + return; + } + for (IntentDefinition.ParamDefinition param : params) { + Map 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()); + } + } + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/ToolExecutionService.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/ToolExecutionService.java new file mode 100644 index 0000000..dd69252 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/ToolExecutionService.java @@ -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 handlerMap; + private final Map llmFormatterMap; + private final KakaoSkillToolClient kakaoSkillToolClient; + private final ToolParamPipeline toolParamPipeline; + + public ToolExecutionService( + IntentDefinitionRegistry intentDefinitionRegistry, + List handlers, + List 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 params = pipeline.getParams(); + FetchOwner fetchOwner = definition.getFetchOwner(); + Map 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 fetchWasDomainData(String intentType, Map params) { + IntentHandler handler = handlerMap.get(intentType); + if (handler == null) { + Map fallback = new HashMap<>(); + fallback.put("status", false); + fallback.put("statusMsg", "WAS handler not registered: " + intentType); + return fallback; + } + Map domainData = handler.fetchDomainData(params); + LlmDomainDataFormatter formatter = llmFormatterMap.get(intentType); + if (formatter != null && domainData != null) { + formatter.enrich(domainData); + } + return domainData; + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/ToolParamPipeline.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/ToolParamPipeline.java new file mode 100644 index 0000000..bd71cf9 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/agent/service/ToolParamPipeline.java @@ -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 rawArguments) { + Map 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()); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/api/FareApiController.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/api/FareApiController.java new file mode 100644 index 0000000..a778c60 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/api/FareApiController.java @@ -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> queryRefund(String carNo) { + log.info("API 호출 : queryRefund(GET) - carNo: {}", carNo); + return ResponseEntity.ok(fareService.getRefundInfo(carNo)); + } + + /** + * Description : 미납내역 조회 메소드 + */ + @GetMapping("/api/fare/unpaid") + public ResponseEntity> queryUnpaid(String carNo) { + log.info("API 호출 : queryUnpaid(GET) - carNo: {}", carNo); + return ResponseEntity.ok(fareService.getUnpaidInfo(carNo)); + } + + /** + * Description : 영업소 전화번호 조회 메소드 + */ + @GetMapping("/api/call/icTel") + public ResponseEntity> queryIcTel(String icName) { + log.info("API 호출 : queryIcTel(GET) - icName: {}", icName); + return ResponseEntity.ok(fareService.getIcTelInfo(icName)); + } + + /** + * Description : 통행요금 조회 메소드 + */ + @GetMapping("/api/fare/searchFare") + public ResponseEntity> querySearchFare(String fromIc, String toIc) { + log.info("API 호출 : querySearchFare(GET) - fromIc: {}, toIc: {}", fromIc, toIc); + return ResponseEntity.ok(fareService.searchFare(fromIc, toIc)); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/formatter/FareRefundLlmFormatter.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/formatter/FareRefundLlmFormatter.java new file mode 100644 index 0000000..c033d3b --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/formatter/FareRefundLlmFormatter.java @@ -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 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)를 이용해 주세요."); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/formatter/FareSearchLlmFormatter.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/formatter/FareSearchLlmFormatter.java new file mode 100644 index 0000000..91c5382 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/formatter/FareSearchLlmFormatter.java @@ -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 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 fare = (Map) 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 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 + "원"; + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/formatter/FareUnpaidLlmFormatter.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/formatter/FareUnpaidLlmFormatter.java new file mode 100644 index 0000000..c314a5b --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/formatter/FareUnpaidLlmFormatter.java @@ -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 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; + } + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/formatter/IcTelLlmFormatter.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/formatter/IcTelLlmFormatter.java new file mode 100644 index 0000000..f19a494 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/formatter/IcTelLlmFormatter.java @@ -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 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()); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/handler/FareRefundHandler.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/handler/FareRefundHandler.java new file mode 100644 index 0000000..4371475 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/handler/FareRefundHandler.java @@ -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 fetchDomainData(Map params) { + return fareService.getRefundInfo(params.get("carNo")); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/handler/FareSearchHandler.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/handler/FareSearchHandler.java new file mode 100644 index 0000000..5621646 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/handler/FareSearchHandler.java @@ -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 fetchDomainData(Map params) { + return fareService.searchFare(params.get("fromIc"), params.get("toIc")); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/handler/FareUnpaidHandler.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/handler/FareUnpaidHandler.java new file mode 100644 index 0000000..5254ed2 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/handler/FareUnpaidHandler.java @@ -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 fetchDomainData(Map params) { + return fareService.getUnpaidInfo(params.get("carNo")); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/handler/IcTelHandler.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/handler/IcTelHandler.java new file mode 100644 index 0000000..61e5eba --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/handler/IcTelHandler.java @@ -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 fetchDomainData(Map params) { + return fareService.getIcTelInfo(params.get("icName")); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/repository/FareRepository.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/repository/FareRepository.java new file mode 100644 index 0000000..5204cc6 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/repository/FareRepository.java @@ -0,0 +1,171 @@ +package com.ex.chatbotapi.domain.fare.repository; + +import com.mongodb.BasicDBObject; +import com.mongodb.client.MongoCollection; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.IOUtils; +import org.bson.Document; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.json.JsonParser; +import org.springframework.boot.json.JsonParserFactory; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import javax.annotation.PostConstruct; +import javax.sql.DataSource; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * 통행요금 관련 데이터 접근 레이어 + */ +@Slf4j +@Repository +@RequiredArgsConstructor +public class FareRepository { + private static final String IC_TEL_FILE = "icTel.json"; + private static final Path EXTERNAL_IC_TEL_PATH = Paths.get("config", IC_TEL_FILE); + + @Qualifier("bpstlDataSource") + private final DataSource bpstlDataSource; + private final MongoTemplate mongoTemplate; + + private Map icTelMap; + + @PostConstruct + public void loadIctelData() throws IOException { + JsonParser parser = JsonParserFactory.getJsonParser(); + try (InputStream inputStream = openIcTelInputStream()) { + StringWriter writer = new StringWriter(); + IOUtils.copy(inputStream, writer, "UTF-8"); + String json = writer.toString(); + icTelMap = parser.parseMap(json); + log.info("icTel.json 로드 완료"); + } + } + + private InputStream openIcTelInputStream() throws IOException { + if (Files.exists(EXTERNAL_IC_TEL_PATH)) { + log.info("icTel.json loaded from external file: {}", EXTERNAL_IC_TEL_PATH.toAbsolutePath()); + return Files.newInputStream(EXTERNAL_IC_TEL_PATH); + } + + log.info("external icTel.json not found. using classpath resource: {}", IC_TEL_FILE); + InputStream classpathResource = this.getClass().getClassLoader().getResourceAsStream(IC_TEL_FILE); + if (classpathResource == null) { + throw new IOException("icTel.json 파일을 찾을 수 없습니다. external/classpath 모두 로드 실패"); + } + return classpathResource; + } + + /** + * 차량번호로 환불내역 조회 + */ + public List> findRefundByCarNo(String carNo) { + String query = "SELECT TOT_RFND_AMT, PECARD_RFND_RSLT_CD " + + "FROM T_STLF_INTG_RFND_VHCL01P1 A " + + "WHERE A.PECARD_RFND_RSLT_CD IN ('G','C','L') " + + "AND A.RFND_VHNO = ? " + + "AND rownum < 2 "; + + JdbcTemplate jdbcTemplate = new JdbcTemplate(bpstlDataSource); + return jdbcTemplate.queryForList(query, carNo); + } + + /** + * 차량번호로 미납내역 조회 + */ + public List> findUnpaidByCarNo(String carNo) { + String query = "WITH INTERCHANGE AS(SELECT IC.HOINST_CD AS BIZ_UNIT, IC.TOLOF_CD AS IC_CODE, IC.KOR_TOLOF_NM AS IC_NAME, IC.USE_YN, BC.ODN5_HOINST_GRP_YN AS BIZ_UNIT_GRP05, CASE WHEN BC.HOINST_CD IN ('00','01','02','08','09','10','11','18','28','34','37','45','46','62') THEN 'Y' ELSE 'N' END AS BIZ_UNIT_GRP_CRDT FROM T_STLA_HOINST01C1 BC, T_STLA_TOLOF_OP01M1 IC WHERE BC.HOINST_CD = IC.HOINST_CD ) SELECT RN_MAX - RNUM ROW_NUM, UR2.*, '0' IN_COL_AMT, '0' IN_COL_EXTRA_AMT, biz_unit OUT_BIZ_UNIT, '0' OUT_COL_AMT, '0' OUT_COL_EXTRA_AMT, '0' WILL_AMT, in_biz_unit IN_BIZ_UNIT, '' WAY_BIZ_UNIT, '0' WAY_COL_AMT, '0' WAY_COL_EXTRA_AMT, '0' WILL_EXTRA_AMT FROM (SELECT ROW_NUMBER() OVER ( ORDER BY WW DESC) -1 AS RNUM, SUM(UNPAID_AMT) OVER() AS TOTAL, COUNT(*) OVER () AS RN_MAX, '' AS DELEAGTE_CODE, SUM(COL_EXTRA_WILL_AMT) OVER() AS TOTAL_EXTRA, SUM(COL_EXTRA_YN) OVER() AS TOTAL_EXTRA_CNT, UR.* FROM (SELECT WW, CAR_NO, CAR_KIND, NVL(IC.IC_NAME, '-') IN_IC, OC.IC_NAME OUT_IC, SAYU, OC.USE_YN OUT_IC_USE_YN, UNPAID_AMT, DG_UNPAID_AMT, ETC_UNPAID_AMT, COL_WILL_AMT, SEPAR_WILL_AMT, COL_EXTRA_WILL_AMT, SEPAR_EXTRA_WILL_AMT, DIV_WILL_AMT, NVL(IC.BIZ_UNIT, '-') IN_BIZ_UNIT, OC.BIZ_UNIT, CASE WHEN UNPAID_AMT >= DIV_WILL_AMT AND OC.BIZ_UNIT_GRP05 = 'Y' THEN DECODE(SAYU, NULL, 'N', 'R') ELSE 'X' END AS R_ACC_YN, CASE WHEN UNPAID_AMT >= DIV_WILL_AMT AND OC.BIZ_UNIT_GRP_CRDT = 'Y' THEN DECODE(SAYU, NULL, 'N', 'R') ELSE 'X' END AS R_CRDT_YN, PAY.IN_IC_CODE, PAY.GEN_IC_CODE AS OUT_IC_CODE, PAY.IC_CODE, WORK_DATE, WORK_NO, SER_NO, LANETYPE, OUT_DATE, OUT_TIME, CAR_CLASS, USER_NAME, STATUS_CODE, PROC_CODE, CASE WHEN COL_EXTRA_WILL_AMT > 0 THEN '1' ELSE '0' END AS COL_EXTRA_YN FROM (SELECT TO_DATE(ED.WOUTP_DATES || ED.WOUTP_HMS , 'YYYYMMDDHH24MISS') WW, ED.NTPD_VHNO CAR_NO, ED.OBU_FRKNCR_CD CAR_KIND, ED.ENRC_TOLOF_CD AS IN_IC_CODE, ED.NTPD_OCRN_TOLOF_CD AS GEN_IC_CODE, ED.NTPD_AFFC_TOLOF_CD AS IC_CODE, CC.CMMN_CD_NM AS SAYU, ED.NTPD_AFFC_TOLOF_CD AS OUT_IC_USE_YN, EC.UNPAID_AMT, NVL(EC.DG_UNPAID_AMT, 0) AS DG_UNPAID_AMT, DECODE(ED.CRGW_SYSTM_CLSS_CD, '0', NVL(EC.ETC_UNPAID_AMT, 0), 0) AS ETC_UNPAID_AMT, EC.COL_WILL_AMT, EC.SEPAR_WILL_AMT, EC.COL_EXTRA_WILL_AMT, EC.SEPAR_EXTRA_WILL_AMT, DECODE(ED.CRGW_SYSTM_CLSS_CD, '1', NVL(EC.ETC_UNPAID_AMT, 0), 0) AS DIV_WILL_AMT, ED.NTPD_AFFC_WORK_DATES AS WORK_DATE, ED.NTPD_AFFC_WORK_NO AS WORK_NO, ED.NTPD_AFFC_VHCL_PROS_NO AS SER_NO, ED.CRGW_SYSTM_CLSS_CD AS LANETYPE, ED.WOUTP_DATES AS OUT_DATE, ED.WOUTP_HMS AS OUT_TIME, ED.PRCPT_FRKNCR_CD AS CAR_CLASS, (SELECT NTPD_CUST_NM FROM T_STLE_NPAFV_RSR01P1 CU WHERE CU.NTPD_AFFC_WORK_DATES = ED.NTPD_AFFC_WORK_DATES AND CU.NTPD_AFFC_TOLOF_CD = ED.NTPD_AFFC_TOLOF_CD AND CU.NTPD_AFFC_WORK_NO = ED.NTPD_AFFC_WORK_NO AND CU.NTPD_AFFC_VHCL_PROS_NO = ED.NTPD_AFFC_VHCL_PROS_NO ) AS USER_NAME, ED.NTPD_AFFC_STAT_CD AS STATUS_CODE, ED.NTPD_AFFC_PROS_CD AS PROC_CODE FROM T_STLE_NPAFV_PATH01P1 ED, (SELECT A.NTPD_AFFC_WORK_DATES AS WORK_DATE, A.NTPD_AFFC_TOLOF_CD AS IC_CODE, A.NTPD_AFFC_WORK_NO AS WORK_NO, A.NTPD_AFFC_VHCL_PROS_NO AS SER_NO, A.CRGW_SYSTM_CLSS_CD AS LANE_TYPE, SUM(B.MNRC_SCDL_TOLL + B.MNRC_SCDL_ATOLL) UNPAID_AMT, SUM(DECODE(B.HOINST_CD, '00', B.MNRC_SCDL_TOLL + B.MNRC_SCDL_ATOLL, 0)) DG_UNPAID_AMT, SUM(DECODE(B.HOINST_CD, '00', 0, B.MNRC_SCDL_TOLL + B.MNRC_SCDL_ATOLL)) ETC_UNPAID_AMT, SUM(B.MNRC_SCDL_TOLL) COL_WILL_AMT, SUM(B.MNRC_SCDL_TOLL) SEPAR_WILL_AMT, SUM(B.MNRC_SCDL_ATOLL) COL_EXTRA_WILL_AMT, SUM(B.MNRC_SCDL_ATOLL) SEPAR_EXTRA_WILL_AMT FROM T_STLE_NPAFV_PATH01P1 A, T_STLF_NTMNRC01L1 B WHERE A.NTPD_AFFC_WORK_DATES = B.NTPD_AFFC_WORK_DATES AND A.NTPD_AFFC_TOLOF_CD = B.NTPD_AFFC_TOLOF_CD AND A.NTPD_AFFC_WORK_NO = B.NTPD_AFFC_WORK_NO AND A.NTPD_AFFC_VHCL_PROS_NO = B.NTPD_AFFC_VHCL_PROS_NO AND A.NTPD_AFFC_STAT_CD < '70' AND A.CRGW_SYSTM_CLSS_CD != '9' AND A.NTPD_VHNO = ? GROUP BY A.NTPD_AFFC_WORK_DATES, A.NTPD_AFFC_TOLOF_CD, A.NTPD_AFFC_WORK_NO, A.NTPD_AFFC_VHCL_PROS_NO, A.CRGW_SYSTM_CLSS_CD ) EC, T_STLA_CMMN_CD01M1 CC WHERE ED.NTPD_AFFC_WORK_DATES = EC.WORK_DATE AND ED.NTPD_AFFC_TOLOF_CD = EC.IC_CODE AND ED.NTPD_AFFC_WORK_NO = EC.WORK_NO AND ED.NTPD_AFFC_VHCL_PROS_NO = EC.SER_NO AND ED.CRGW_SYSTM_CLSS_CD = EC.LANE_TYPE AND ED.NTPD_CD = CC.CMMN_CD AND CC.CMMN_GRP_CD = 'NTPD_CD' AND NOT (ED.NTPD_AFFC_TOLOF_CD = '094' AND ED.NTPD_AFFC_STAT_CD = '60') UNION ALL SELECT /*+ USE_NL(A B C)*/ TO_DATE(A.OUT_DATE||A.OUT_HHMISS, 'YYYYMMDDHH24MISS') WW , A.VIO_CAR_NO CAR_NO , A.OBU_CAR_CLASS CAR_KIND , A.IN_IC_CODE , A.IC_CODE AS GEN_IC_CODE , A.IC_CODE , (SELECT CC.CMMN_CD_NM FROM T_STLA_CMMN_CD01M1 CC WHERE CC.CMMN_GRP_CD ='HIPAS_VIOL_CD' AND A.VIO_CODE = CC.CMMN_CD ) AS SAYU , A.IC_CODE AS OUT_IC_USE_YN , A.AMT AS UNPAID_AMT , DG_UNPAID_AMT , 0 as ETC_UNPAID_AMT , A.AMT AS COL_WILL_AMT , A.AMT AS SEPAR_WILL_AMT , 0 AS COL_EXTRA_WILL_AMT , 0 as SEPAR_EXTRA_WILL_AMT , A.ETC_AMT as DIV_WILL_AMT , A.WORK_DATE , A.WORK_NO , A.SER_NO , 'V' AS LANETYPE , A.OUT_DATE , A.OUT_HHMISS as OUT_TIME , A.TRD_CAR_CLASS as CAR_CLASS , A.VIO_NAME as USER_NAME , '10' as STATUS_CODE , '10' as PROC_CODE FROM (SELECT A.WORK_DATES AS WORK_DATE , A.TOLOF_CD AS IC_CODE , A.WORK_NO , A.VHCL_PROS_NO AS SER_NO , A.VIOL_VHNO AS VIO_CAR_NO , A.WOUTP_DATES AS PROC_DATEHHMISS , replace('���ᱳ', '%', '') VIO_NAME , A.HIPAS_VIOL_CD AS VIO_CODE , B.ENRC_TOLOF_CD AS IN_IC_CODE , B.WOUTP_DATES AS OUT_DATE , B.WOUTP_HMS AS OUT_HHMISS , C.PRCPT_FRKNCR_CD AS TRD_CAR_CLASS , C.OBU_FRKNCR_CD AS OBU_CAR_CLASS , NVL((SELECT SUM(DECODE(X.HOINST_CD, '00', X.MNRC_SCDL_TOLL, 0)) FROM T_STLK_WHVIOL_PROS_AMT01P1 X WHERE A.WORK_DATES = X.WORK_DATES AND A.TOLOF_CD = X.TOLOF_CD AND A.WORK_NO = X.WORK_NO AND A.VHCL_PROS_NO = X.VHCL_PROS_NO), 0) AS DG_UNPAID_AMT , NVL((SELECT sum(X.MNRC_SCDL_TOLL) FROM T_STLK_WHVIOL_PROS_AMT01P1 X WHERE A.WORK_DATES = X.WORK_DATES AND A.TOLOF_CD = X.TOLOF_CD AND A.WORK_NO = X.WORK_NO AND A.VHCL_PROS_NO = X.VHCL_PROS_NO), 0) AS AMT , NVL((SELECT sum(X.MNRC_SCDL_TOLL) FROM T_STLK_WHVIOL_PROS_AMT01P1 X WHERE A.WORK_DATES = X.WORK_DATES AND A.TOLOF_CD = X.TOLOF_CD AND A.WORK_NO = X.WORK_NO AND A.VHCL_PROS_NO = X.VHCL_PROS_NO AND x.HOINST_CD != '00'), 0) AS ETC_AMT FROM T_STLK_WHVIOL_PROS01P1 A , T_STLK_WHVIOL_PROS_PATH01P1 B , T_STLK_HIPASW_VIOL01P1 C , T_STLA_TOLOF_OP01M1 D WHERE 1 = 1 AND A.VIOL_VHNO = ? AND A.WORK_DATES = TO_CHAR(SYSDATE, 'YYYYMMDD') AND A.NTPD_MNRC_STAT_CD IN ('10', '20') AND A.TOLOF_CD = D.TOLOF_CD AND D.HOINST_CD IN (SELECT HOINST_CD FROM T_STLA_HOINST01C1 WHERE ODN5_HOINST_GRP_YN = 'Y') AND A.WORK_DATES = B.WORK_DATES AND A.TOLOF_CD = B.TOLOF_CD AND A.WORK_NO = B.WORK_NO AND A.VHCL_PROS_NO = B.VHCL_PROS_NO AND B.PATH_NO = '1' AND A.WORK_DATES = C.WORK_DATES(+) AND A.TOLOF_CD = C.TOLOF_CD(+) AND A.WORK_NO = C.WORK_NO(+) AND A.VHCL_PROS_NO = C.VHCL_PROS_NO(+) ) A ) PAY, INTERCHANGE IC, INTERCHANGE OC WHERE PAY.IN_IC_CODE = IC.IC_CODE(+) AND PAY.IC_CODE = OC.IC_CODE ORDER BY WW ) UR WHERE UNPAID_AMT > 0 ) UR2 ORDER BY WW DESC "; + + JdbcTemplate jdbcTemplate = new JdbcTemplate(bpstlDataSource); + return jdbcTemplate.queryForList(query, carNo, carNo); + } + + /** + * 영업소 이름으로 전화번호 조회 + */ + public Map findIcTelByName(String icName) { + String query = "SELECT TOLOF_CD, KOR_TOLOF_NM, TOLOF_TELNO " + + "FROM T_STLA_TOLOF_OP01M1 " + + "WHERE KOR_TOLOF_NM LIKE ? " + + "ORDER BY " + + " CASE WHEN KOR_TOLOF_NM = ? THEN 0 " + + " WHEN KOR_TOLOF_NM LIKE ? THEN 1 " + + " ELSE 2 " + + " END"; + + JdbcTemplate jdbcTemplate = new JdbcTemplate(bpstlDataSource); + List> results = jdbcTemplate.queryForList( + query, + "%" + icName + "%", + icName, + icName + "%"); + + if (!results.isEmpty()) { + return results.get(0); + } + return null; + } + + /** + * MongoDB에서 영업소 코드 조회 + */ + public Document findIcInfoByName(String icName) { + BasicDBObject condition = new BasicDBObject(); + condition.put("ic_name", icName); + MongoCollection icInfoCol = mongoTemplate.getCollection("ic_info"); + return icInfoCol.find(condition).first(); + } + + /** + * MongoDB에서 영업소 이름 prefix 후보 조회 + */ + public List findIcInfoByNamePrefix(String icName, int limit) { + BasicDBObject condition = new BasicDBObject(); + condition.put("ic_name", new BasicDBObject("$regex", "^" + Pattern.quote(icName))); + MongoCollection icInfoCol = mongoTemplate.getCollection("ic_info"); + return icInfoCol.find(condition).limit(limit).into(new ArrayList<>()); + } + + /** + * MongoDB에서 영업소 이름 포함 후보 조회 + */ + public List findIcInfoByNameContains(String icName, int limit) { + BasicDBObject condition = new BasicDBObject(); + condition.put("ic_name", new BasicDBObject("$regex", Pattern.quote(icName))); + MongoCollection icInfoCol = mongoTemplate.getCollection("ic_info"); + return icInfoCol.find(condition).limit(limit).into(new ArrayList<>()); + } + + /** + * MongoDB에서 통행요금 조회 + */ + public Document findFareByIcNames(String fromIc, String toIc) { + BasicDBObject condition = new BasicDBObject(); + condition.put("src_name", fromIc); + condition.put("dst_name", toIc); + condition.put("table_type", "01"); + condition.put("system_type", "1"); + + MongoCollection trsAmtCol = mongoTemplate.getCollection("trs_amt"); + return trsAmtCol.find(condition).first(); + } + + /** + * icTel.json 데이터 조회 + */ + public Map getIcTelMap() { + return icTelMap; + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/service/FareAmountHelper.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/service/FareAmountHelper.java new file mode 100644 index 0000000..fb9fc48 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/service/FareAmountHelper.java @@ -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 fareData, Document trsDoc) { + List totalList = firstNonNullList( + readIntegerList(trsDoc.get("sum")), + readAmtList(trsDoc, "sum") + ); + if (totalList == null || totalList.size() < 6) { + return; + } + + List kecList = firstNonNullList( + readAmtList(trsDoc, "00"), + totalList + ); + List 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 buildWebBreakdown( + String fromIc, + String toIc, + List totalList, + List kecList, + List privateList + ) { + Map 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> 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 vehicleEntry( + int index, + List totalList, + List kecList, + List privateList, + boolean compact + ) { + Map 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 fareData, List 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 fareData, + String prefix, + List 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 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 readIntegerList(Object value) { + if (!(value instanceof List)) { + return null; + } + List 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 subtractLists(List totalList, List kecList) { + List 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 firstNonNullList(List... lists) { + for (List list : lists) { + if (list != null && !list.isEmpty()) { + return list; + } + } + return null; + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/service/FareService.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/service/FareService.java new file mode 100644 index 0000000..eb0b165 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/fare/service/FareService.java @@ -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 getRefundInfo(String carNo) { + log.info("{} 차량 환불내역 조회 시작", carNo); + + Map response = new HashMap<>(); + + try { + List> 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 getUnpaidInfo(String carNo) { + log.info("{} 차량 미납내역 조회 시작", carNo); + + Map response = new HashMap<>(); + + try { + List> list = fareRepository.findUnpaidByCarNo(carNo); + + long sumAmt = 0; + for (Map 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 getIcTelInfo(String icName) { + log.info("{} 영업소 연락처 조회를 시작합니다", icName); + + Map response = new HashMap<>(); + + try { + if (icName == null || icName.isEmpty()) { + log.warn("영업소 이름이 없습니다"); + response.put("status", false); + response.put("statusMsg", "영업소 이름이 없습니다."); + return response; + } + + Map 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 searchFare(String fromIc, String toIc) { + log.info("{}와 {} 간의 요금을 계산합니다", fromIc, toIc); + + Map 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 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 icTelMap = fareRepository.getIcTelMap(); + + if (fromCodeInt < 100 && toCodeInt < 100) { + @SuppressWarnings("unchecked") + Map fromIcTel = (Map) icTelMap.get(fromIc); + if (fromIcTel == null || fromIcTel.get("amt") == null) { + response.put("status", false); + response.put("statusMsg", "해당구간의 통행요금을 찾지 못했습니다."); + return response; + } + + @SuppressWarnings("unchecked") + List fromAmt = (List) fromIcTel.get("amt"); + Map 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 fromIcTel = (Map) icTelMap.get(fromIc); + if (fromIcTel == null || fromIcTel.get("amt") == null) { + response.put("status", false); + response.put("statusMsg", "해당구간의 통행요금을 찾지 못했습니다."); + return response; + } + + @SuppressWarnings("unchecked") + List fromAmt = (List) fromIcTel.get("amt"); + @SuppressWarnings("unchecked") + List outIcs = (List) fromIcTel.get("out"); + + Map 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 toIcTel = (Map) icTelMap.get(toIc); + if (toIcTel == null || toIcTel.get("amt") == null) { + response.put("status", false); + response.put("statusMsg", "해당구간의 통행요금을 찾지 못했습니다."); + return response; + } + + @SuppressWarnings("unchecked") + List toAmt = (List) toIcTel.get("amt"); + @SuppressWarnings("unchecked") + List outIcs = (List) toIcTel.get("out"); + + Map 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; + } + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/api/RagApiController.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/api/RagApiController.java new file mode 100644 index 0000000..d145a0c --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/api/RagApiController.java @@ -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> 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> checkHealth() { + log.info("API 호출 : checkHealth(GET)"); + return ResponseEntity.ok(ragService.checkHealth()); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/client/RagServerClient.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/client/RagServerClient.java new file mode 100644 index 0000000..6a2a09a --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/client/RagServerClient.java @@ -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 entity = new HttpEntity<>(request, headers); + + ResponseEntity 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 entity = new HttpEntity<>(request, headers); + ResponseEntity 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; + } + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/dto/IntentAnalysisRequest.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/dto/IntentAnalysisRequest.java new file mode 100644 index 0000000..462b6fe --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/dto/IntentAnalysisRequest.java @@ -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 pendingParams; + private List> intentDefinitions; +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/dto/IntentAnalysisResponse.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/dto/IntentAnalysisResponse.java new file mode 100644 index 0000000..32b8068 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/dto/IntentAnalysisResponse.java @@ -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 params = new HashMap<>(); + private String routeType; + private Boolean needsClarification; + private String clarificationQuestion; + private String reason; + + public double confidenceOrZero() { + return confidence != null ? confidence : 0.0; + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/dto/RagQueryRequest.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/dto/RagQueryRequest.java new file mode 100644 index 0000000..0cdbca0 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/dto/RagQueryRequest.java @@ -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 domainData; + /** 의도 타입 (프롬프트 컨텍스트 제공용) */ + private String intentType; +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/dto/RagServerRequest.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/dto/RagServerRequest.java new file mode 100644 index 0000000..a834cd7 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/dto/RagServerRequest.java @@ -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 domainData; + /** 의도 타입 (Python 측 field: intent_type) */ + private String intentType; +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/dto/RagServerResponse.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/dto/RagServerResponse.java new file mode 100644 index 0000000..2074881 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/dto/RagServerResponse.java @@ -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 matchedQuestions; + /** 유사도 점수 배열 */ + private List scores; + /** 참조한 문서/질문 개수 */ + @JsonProperty("num_references") + private Integer numReferences; + /** 공식 FAQ 상세 URL 목록 */ + @JsonProperty("faq_urls") + private List faqUrls; + /** 참조 문서 메타데이터 */ + private List> references; +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/service/RagService.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/service/RagService.java new file mode 100644 index 0000000..22e9349 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/rag/service/RagService.java @@ -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 processQuestion(RagQueryRequest request) { + log.info("RAG 질문 처리 시작 - 질문: {}, 봇ID: {}", + request.getQuestion(), request.getBotId()); + + Map 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 checkHealth() { + log.info("RAG 서버 헬스체크 시작"); + + Map result = new HashMap<>(); + + try { + String healthUrl = ragConfig.getHealthUrl(); + log.info("헬스체크 URL: {}", healthUrl); + + ResponseEntity response = restTemplate.getForEntity(healthUrl, Map.class); + Map healthData = (Map) 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("질문이 비어있습니다."); + } + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/api/RestAreaApiController.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/api/RestAreaApiController.java new file mode 100644 index 0000000..c60c514 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/api/RestAreaApiController.java @@ -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> queryPriceInfo(String restAreaName) { + log.info("API 호출 : queryPriceInfo(GET) - restAreaName: {}", restAreaName); + return ResponseEntity.ok(restAreaService.getPriceInfo(restAreaName)); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/formatter/BrandShopLlmFormatter.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/formatter/BrandShopLlmFormatter.java new file mode 100644 index 0000000..eb3cf24 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/formatter/BrandShopLlmFormatter.java @@ -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 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> list = (List>) domainData.get("list"); + if (list == null || list.isEmpty()) { + domainData.put("llmSummary", restAreaName + " 휴게소 매장 정보가 없습니다."); + return; + } + + // 브랜드명 목록 (중복 제거) + List 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()); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/formatter/FoodListLlmFormatter.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/formatter/FoodListLlmFormatter.java new file mode 100644 index 0000000..b4aef12 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/formatter/FoodListLlmFormatter.java @@ -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 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 msgMap = (Map) 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 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()); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/formatter/GasstationLlmFormatter.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/formatter/GasstationLlmFormatter.java new file mode 100644 index 0000000..32ed416 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/formatter/GasstationLlmFormatter.java @@ -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 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> list = (List>) domainData.get("list"); + if (list == null || list.isEmpty()) { + domainData.put("llmSummary", restAreaName + " 휴게소 주유 정보가 없습니다."); + return; + } + + StringBuilder sb = new StringBuilder(); + for (Map 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 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 "주유소"; + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/formatter/RestAreaPriceLlmFormatter.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/formatter/RestAreaPriceLlmFormatter.java new file mode 100644 index 0000000..fc8851a --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/formatter/RestAreaPriceLlmFormatter.java @@ -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 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>> priceInfo = + (Map>>) 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>> e : priceInfo.entrySet()) { + if (restCount >= restLimit) { + sb.append("- …외 휴게소 ").append(priceInfo.size() - restLimit).append("곳 더 있음\n"); + break; + } + String restNm = e.getKey(); + List> rows = e.getValue(); + sb.append("- ").append(restNm).append("\n"); + int n = 0; + for (Map 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()); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/handler/RestAreaPriceHandler.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/handler/RestAreaPriceHandler.java new file mode 100644 index 0000000..c20d6e0 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/handler/RestAreaPriceHandler.java @@ -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 fetchDomainData(Map params) { + return restAreaService.getPriceInfo(params.get("restAreaName")); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/repository/RestAreaRepository.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/repository/RestAreaRepository.java new file mode 100644 index 0000000..cda9834 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/repository/RestAreaRepository.java @@ -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> 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(); + } + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/service/RestAreaService.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/service/RestAreaService.java new file mode 100644 index 0000000..fa9d246 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/restarea/service/RestAreaService.java @@ -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 getPriceInfo(String restAreaName) { + log.info("{} 휴게소 가격공시 조회를 시작합니다", restAreaName); + + Map response = new HashMap<>(); + + try { + List> restAreaInfo = restAreaRepository + .findPriceInfoByRestAreaName(restAreaName); + + if (!restAreaInfo.isEmpty()) { + Map>> priceInfoByRestArea = new HashMap<>(); + + for (Map 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 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; + } + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/api/TroadApiController.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/api/TroadApiController.java new file mode 100644 index 0000000..607b9cc --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/api/TroadApiController.java @@ -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> 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> queryCongestion(String routeName) { + log.info("API 호출 : queryCongestion(GET) - routeName: {}", routeName); + return ResponseEntity.ok(troadService.getCongestion(routeName)); + } + + @GetMapping("/api/troad/breakingNews") + public ResponseEntity> queryBreakingNews(String routeName) { + log.info("API 호출 : queryBreakingNews(GET) - routeName: {}", routeName); + return ResponseEntity.ok(troadService.getBreakingNews(routeName)); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/formatter/TroadBreakingNewsLlmFormatter.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/formatter/TroadBreakingNewsLlmFormatter.java new file mode 100644 index 0000000..ce21b1f --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/formatter/TroadBreakingNewsLlmFormatter.java @@ -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 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 routeInfo = (Map) domainData.get("RouteInfo"); + @SuppressWarnings("unchecked") + List> newsS = (List>) domainData.get("RouteBreakingNewsS"); + @SuppressWarnings("unchecked") + List> newsE = (List>) 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> rows, int cap) { + sb.append("\n【").append(label).append("】\n"); + if (rows == null || rows.isEmpty()) { + sb.append("- 속보 없음\n"); + return; + } + int n = 0; + for (Map 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++; + } + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/formatter/TroadCongestionLlmFormatter.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/formatter/TroadCongestionLlmFormatter.java new file mode 100644 index 0000000..103f33e --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/formatter/TroadCongestionLlmFormatter.java @@ -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 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 routeInfo = (Map) domainData.get("RouteInfo"); + @SuppressWarnings("unchecked") + List> routeInfoS = (List>) domainData.get("RouteInfoS"); + @SuppressWarnings("unchecked") + List> routeInfoE = (List>) 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> rows, int cap) { + sb.append("\n【").append(label).append("】\n"); + int n = 0; + for (Map 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; + } + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/formatter/TroadSearchRouteLlmFormatter.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/formatter/TroadSearchRouteLlmFormatter.java new file mode 100644 index 0000000..38d12f2 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/formatter/TroadSearchRouteLlmFormatter.java @@ -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 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 summary = (Map) domainData.get("Summary"); + @SuppressWarnings("unchecked") + Map bunch = (Map) domainData.get("Bunch"); + @SuppressWarnings("unchecked") + Map icdt = (Map) 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 icdt, String type) { + List 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 extractIcdtMessages(Map icdt, String incidentType, int maxLines) { + List out = new ArrayList<>(); + if (icdt == null) { + return out; + } + Object raw = icdt.get("IcdtData"); + if (!(raw instanceof List)) { + return out; + } + + List icdtDatas = (List) raw; + for (Object icdtData : icdtDatas) { + if (out.size() >= maxLines) { + break; + } + Map 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; + } + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/handler/TroadBreakingNewsHandler.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/handler/TroadBreakingNewsHandler.java new file mode 100644 index 0000000..9b7d439 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/handler/TroadBreakingNewsHandler.java @@ -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 fetchDomainData(Map params) { + return troadService.getBreakingNews(params.get("routeName")); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/handler/TroadCongestionHandler.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/handler/TroadCongestionHandler.java new file mode 100644 index 0000000..55f00a2 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/handler/TroadCongestionHandler.java @@ -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 fetchDomainData(Map params) { + return troadService.getCongestion(params.get("routeName")); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/handler/TroadSearchRouteHandler.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/handler/TroadSearchRouteHandler.java new file mode 100644 index 0000000..44fea18 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/handler/TroadSearchRouteHandler.java @@ -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 fetchDomainData(Map 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); + } + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/service/TroadService.java b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/service/TroadService.java new file mode 100644 index 0000000..3777a15 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/java/com/ex/chatbotapi/domain/troad/service/TroadService.java @@ -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 exceptionRoute = Arrays.asList("남해선", "서울문산선", "중부내륙선", "호남선", "수도권제2순환선"); + + /** + * 영업소(톨게이트) 간 경로 조회 + */ + public Map searchRoute(String fromIc, String toIc) throws IOException { + BasicDBObject condition = new BasicDBObject(); + condition.put("src", fromIc); + condition.put("dst", toIc); + Gson gson = new Gson(); + + MongoCollection icRoadInfo = mongoTemplate.getCollection("icRoadInfo"); + Document icMinGRS = icRoadInfo.find(condition).first(); + + Map 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 resMap = gson.fromJson(result, Map.class); + + @SuppressWarnings("unchecked") + Map summary = (LinkedTreeMap) resMap.get("Summary"); + @SuppressWarnings("unchecked") + Map icdt = (LinkedTreeMap) resMap.get("Icdt"); + @SuppressWarnings("unchecked") + Map bunch = (LinkedTreeMap) 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 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 response = new HashMap<>(); + Map 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> routeQuickReplyInfo; + if (routeName.equals("수도권순환선")) { + routeQuickReplyInfo = new ArrayList<>(); + Map firstMap = new HashMap<>(); + firstMap.put("ROUTENM", "수도권제1순환선"); + firstMap.put("ROUTECD", ""); + firstMap.put("GRPID", ""); + Map 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> routeInfoS = troadTemplate.queryForList(queryGetRouteCongestionInfoS, grpId, + routeCd, grpId); + List> 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 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 response = new HashMap<>(); + Map 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> routeQuickReplyInfo; + if (routeName.equals("수도권순환선")) { + routeQuickReplyInfo = new ArrayList<>(); + Map firstMap = new HashMap<>(); + firstMap.put("ROUTENM", "수도권제1순환선"); + firstMap.put("ROUTECD", ""); + firstMap.put("GRPID", ""); + Map 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> routeBreakingNewsS = troadTemplate.queryForList(queryGetBreakingNewsS, routeCd, + "%" + routeNm + "%"); + List> 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; + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/resources/application.yml b/chatbotApi-chatbot2.0-agent/src/main/resources/application.yml new file mode 100644 index 0000000..6c73b6e --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/resources/application.yml @@ -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 diff --git a/chatbotApi-chatbot2.0-agent/src/main/resources/icTel.json b/chatbotApi-chatbot2.0-agent/src/main/resources/icTel.json new file mode 100644 index 0000000..3aa815b --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/resources/icTel.json @@ -0,0 +1,2240 @@ +{ + "중랑": { + "tel": "070-4220-6490", + "code": "004" + }, + "화성비봉": { + "tel": "1877-1504", + "code": "005" + }, + "서별내": { + "tel": "031-515-6091", + "code": "006" + }, + "동별내": { + "tel": "031-515-6092", + "code": "007" + }, + "인천": { + "tel": "032-718-2190", + "code": "011", + "amt": [900, 900, 900, 1000, 1000, 450], + "out": ["서시흥", "군자", "서서울", "서울", "동서울"] + }, + "인천계양": { + "tel": "032-545-0127", + "code": "011" + }, + "순천만": { + "tel": "061-882-2626", + "code": "013", + "amt": [900, 900, 900, 1000, 1100, 450], + "out": ["남순천", "동순천"] + }, + "소태": { + "tel": "062-220-8880", + "code": "016" + }, + "송암": { + "tel": "062-670-7100", + "code": "017" + }, + "유덕": { + "tel": "062-370-0517", + "code": "018" + }, + "금천": { + "tel": "070-4435-0641", + "code": "019" + }, + "고모": { + "tel": "053-790-1600", + "code": "023" + }, + "삼덕": { + "tel": "053-791-6633", + "code": "024" + }, + "동부산": { + "tel": "051-722-7515", + "code": "025" + }, + "호원": { + "tel": "031-894-7960", + "code": "026" + }, + "예전": { + "tel": "052-710-3066", + "code": "027" + }, + "염포산": { + "tel": "052-710-3066", + "code": "028" + }, + "용마터널": { + "tel": "031-566-5300", + "code": "030" + }, + "의왕": { + "tel": "031-458-3091", + "code": "031" + }, + "일산": { + "tel": "없음", + "code": "032" + }, + "고양": { + "tel": "031-994-6300", + "code": "033" + }, + "통일로": { + "tel": "031-994-6400", + "code": "034" + }, + "송추": { + "tel": "031-894-6200", + "code": "036" + }, + "별내": { + "tel": "031-522-6400", + "code": "037" + }, + "불암산": { + "tel": "031-522-6300", + "code": "038" + }, + "안양과천": { + "tel": "031-756-6982", + "code": "040" + }, + "북판교": { + "tel": "031-756-6982", + "code": "041" + }, + "북청계": { + "tel": "031-756-6982", + "code": "042" + }, + "북의왕": { + "tel": "031-756-6982", + "code": "043" + }, + "일산대교": { + "tel": "031-988-6585", + "code": "044" + }, + "한밭": { + "tel": "042-670-2700", + "code": "047" + }, + "대화": { + "tel": "042-670-2700", + "code": "048" + }, + "앞산": { + "tel": "1661-5388", + "code": "049" + }, + "앞산터널": { + "tel": "1661-5388", + "code": "049" + }, + "파동": { + "tel": "1661-5388", + "code": "050" + }, + "파동고가교": { + "tel": "1661-5388", + "code": "050" + }, + "남인천": { + "tel": "032-718-2191", + "code": "053", + "amt": [900, 900, 900, 1000, 1100, 450], + "out": ["서시흥", "군자", "서서울", "서울", "동서울"] + }, + "팔룡터널": { + "tel": "055-251-8599", + "code": "055" + }, + "일로": { + "tel": "061-882-2617", + "code": "056", + "amt": [800, 800, 800, 800, 800, 400], + "out": ["목포", "서영암"] + }, + "다사": { + "tel": "053-717-2784", + "code": "057", + "amt": [1100, 1100, 1100, 1100, 1100, 550], + "out": ["북달성"] + }, + "선암": { + "tel": "070-4437-1715", + "code": "060" + }, + "성남": { + "tel": "031-327-2136", + "code": "061", + "amt": [1000, 1000, 1000, 1100, 1200, 500], + "out": ["서시흥", "군자", "서서울", "서울", "동서울"] + }, + "구리남양주": { + "tel": "031-327-2137", + "code": "062", + "amt": [900, 900, 900, 1000, 1100, 450], + "out": ["서시흥", "군자", "서서울", "서울", "동서울"] + }, + "토평": { + "tel": "031-327-2138", + "code": "063", + "amt": [800, 800, 800, 800, 800, 400], + "out": ["서시흥", "군자", "서서울", "서울", "동서울"] + }, + "청계": { + "tel": "031-327-2139", + "code": "064", + "amt": [1000, 1000, 1000, 1100, 1200, 500], + "out": ["서시흥", "군자", "서서울", "서울", "동서울"] + }, + "판교": { + "tel": "031-327-2129", + "code": "065", + "amt": [1000, 1000, 1000, 1100, 1200, 500], + "out": ["서시흥", "군자", "서서울", "서울", "동서울"] + }, + "하남": { + "tel": "031-327-2131", + "code": "066", + "amt": [800, 900, 900, 900, 900, 400], + "out": ["서시흥", "군자", "서서울", "서울", "동서울"] + }, + "시흥": { + "tel": "031-327-2104", + "code": "067", + "amt": [900, 900, 900, 900, 1000, 450], + "out": ["서시흥", "군자", "서서울", "서울", "동서울"] + }, + "김포": { + "tel": "031-327-2101", + "code": "068", + "amt": [900, 900, 900, 1000, 1100, 450], + "out": ["서시흥", "군자", "서서울", "서울", "동서울"] + }, + "고촌": { + "tel": "031-985-0456", + "code": "068" + }, + "대왕판교": { + "tel": "031-327-2130", + "code": "069", + "amt": [1000, 1000, 1000, 1100, 1200, 500], + "out": ["서울"] + }, + "미시령터널": { + "tel": "033-635-7338", + "code": "070" + }, + "이패": { + "tel": "031-511-7676", + "code": "071" + }, + "덕소삼패": { + "tel": "031-577-2733", + "code": "074" + }, + "명지": { + "tel": "051-271-8585", + "code": "075" + }, + "을숙도대교": { + "tel": "051-271-8585", + "code": "075" + }, + "가덕": { + "tel": "051-714-6873", + "code": "076" + }, + "거제": { + "tel": "055-736-6891", + "code": "077" + }, + "창원": { + "tel": "055-213-7200", + "code": "078" + }, + "녹산": { + "tel": "055-213-7266", + "code": "079" + }, + "수정산터널": { + "tel": "051-893-5011", + "code": "080" + }, + "금토분기점": { + "tel": "1599-2505", + "code": "082" + }, + "인천대교": { + "tel": "032-745-8200", + "code": "083" + }, + "서수지": { + "tel": "1599-2505", + "code": "084" + }, + "금토": { + "tel": "1599-2505", + "code": "085" + }, + "물왕": { + "tel": "031-8084-8940", + "code": "086" + }, + "연성": { + "tel": "031-313-3013", + "code": "087" + }, + "고잔": { + "tel": "032-422-3015", + "code": "088" + }, + "부산항대교": { + "tel": "1544-3888", + "code": "090" + }, + "내서": { + "tel": "055-711-2831", + "code": "092", + "amt": [1000, 1000, 1000, 1100, 1100, 500], + "out": ["산인", "칠원", "마산"] + }, + "개금": { + "tel": "051-790-7052", + "code": "093" + }, + "광안대로": { + "tel": "051-780-0042", + "code": "094" + }, + "백양터널": { + "tel": "051-309-2600", + "code": "095" + }, + "마창대교": { + "tel": "055-710-2000", + "code": "096" + }, + "인천공항": { + "tel": "032-560-6200", + "code": "097" + }, + "북인천": { + "tel": "032-560-6300", + "code": "098" + }, + "청라": { + "tel": "032-560-6333", + "code": "099" + }, + "서울": { + "tel": "031-327-2124", + "code": "101" + }, + "동수원": { + "tel": "031-327-2108", + "code": "102" + }, + "수원신갈": { + "tel": "031-327-2125", + "code": "103" + }, + "지곡": { + "tel": "055-711-2846", + "code": "104" + }, + "기흥": { + "tel": "031-327-2126", + "code": "105" + }, + "오산": { + "tel": "031-327-2128", + "code": "106" + }, + "안성": { + "tel": "031-327-2151", + "code": "107" + }, + "천안": { + "tel": "041-411-2426", + "code": "108" + }, + "계룡": { + "tel": "041-411-2400", + "code": "109" + }, + "두계": { + "tel": "042-841-2504", + "code": "109" + }, + "목천": { + "tel": "041-411-2427", + "code": "110" + }, + "청주": { + "tel": "043-756-2323", + "code": "111" + }, + "남청주": { + "tel": "043-756-2324", + "code": "112" + }, + "신탄진": { + "tel": "042-714-2499", + "code": "113" + }, + "남대전": { + "tel": "042-714-2492", + "code": "114" + }, + "대전": { + "tel": "042-714-2498", + "code": "115" + }, + "옥천": { + "tel": "043-756-2325", + "code": "116" + }, + "금강": { + "tel": "043-756-2326", + "code": "117" + }, + "영동": { + "tel": "043-756-2327", + "code": "118" + }, + "판암": { + "tel": "042-714-2493", + "code": "119" + }, + "황간": { + "tel": "043-756-2328", + "code": "120" + }, + "추풍령": { + "tel": "054-706-2714", + "code": "121" + }, + "김천": { + "tel": "054-706-2711", + "code": "122" + }, + "구미": { + "tel": "054-706-2708", + "code": "123" + }, + "상주": { + "tel": "054-706-2719", + "code": "124" + }, + "남구미": { + "tel": "054-706-2707", + "code": "125" + }, + "왜관": { + "tel": "054-706-2741", + "code": "126" + }, + "서대구": { + "tel": "053-717-2797", + "code": "127" + }, + "금왕꽃동네": { + "tel": "043-756-2308", + "code": "128" + }, + "북대구": { + "tel": "053-717-2796", + "code": "129" + }, + "동김천": { + "tel": "054-706-2712", + "code": "130" + }, + "경산": { + "tel": "054-706-2700", + "code": "131" + }, + "영천": { + "tel": "054-706-2731", + "code": "132" + }, + "건천": { + "tel": "054-706-2701", + "code": "133" + }, + "선산": { + "tel": "054-706-2709", + "code": "134" + }, + "경주": { + "tel": "054-706-2702", + "code": "135" + }, + "음성": { + "tel": "043-756-2306", + "code": "136" + }, + "통도사": { + "tel": "052-701-2893", + "code": "137" + }, + "양산": { + "tel": "055-711-2822", + "code": "138" + }, + "서울산": { + "tel": "052-701-2858", + "code": "139" + }, + "부산": { + "tel": "051-793-2882", + "code": "140" + }, + "군북": { + "tel": "055-711-2841", + "code": "142" + }, + "장지": { + "tel": "055-711-2840", + "code": "143" + }, + "함안": { + "tel": "055-711-2839", + "code": "144" + }, + "산인": { + "tel": "055-711-2842", + "code": "145" + }, + "동창원": { + "tel": "055-711-2834", + "code": "146" + }, + "진례": { + "tel": "055-711-2812", + "code": "147" + }, + "서김해": { + "tel": "055-711-2806", + "code": "148" + }, + "동김해": { + "tel": "055-711-2808", + "code": "149" + }, + "북부산": { + "tel": "051-793-2881", + "code": "150" + }, + "북대전": { + "tel": "042-714-2497", + "code": "151" + }, + "유성": { + "tel": "042-714-2496", + "code": "152" + }, + "서대전": { + "tel": "042-714-2495", + "code": "153" + }, + "안영": { + "tel": "042-714-2494", + "code": "154" + }, + "논산": { + "tel": "041-411-2407", + "code": "155" + }, + "익산": { + "tel": "063-712-2518", + "code": "156" + }, + "삼례": { + "tel": "063-712-2523", + "code": "157" + }, + "전주": { + "tel": "063-712-2527", + "code": "158" + }, + "서전주": { + "tel": "063-712-2522", + "code": "159" + }, + "김제": { + "tel": "063-712-2505", + "code": "160" + }, + "금산사": { + "tel": "063-712-2506", + "code": "161" + }, + "태인": { + "tel": "063-712-2531", + "code": "162" + }, + "정읍": { + "tel": "063-712-2529", + "code": "163" + }, + "무주": { + "tel": "063-712-2513", + "code": "164" + }, + "백양사": { + "tel": "061-882-2632", + "code": "165" + }, + "장성": { + "tel": "061-882-2634", + "code": "166" + }, + "광주": { + "tel": "061-882-2631", + "code": "167" + }, + "덕유산": { + "tel": "063-712-2514", + "code": "168" + }, + "장수": { + "tel": "063-712-2526", + "code": "169" + }, + "서상": { + "tel": "055-711-2845", + "code": "170" + }, + "마성": { + "tel": "031-327-2113", + "code": "171" + }, + "용인": { + "tel": "031-327-2140", + "code": "172" + }, + "양지": { + "tel": "031-327-2141", + "code": "173" + }, + "춘천": { + "tel": "033-812-2212", + "code": "174" + }, + "덕평": { + "tel": "031-327-2142", + "code": "175" + }, + "이천": { + "tel": "031-327-2143", + "code": "176" + }, + "여주": { + "tel": "031-327-2148", + "code": "177" + }, + "문막": { + "tel": "033-812-2200", + "code": "178" + }, + "홍천": { + "tel": "033-812-2211", + "code": "179" + }, + "원주": { + "tel": "033-812-2201", + "code": "180" + }, + "새말": { + "tel": "033-812-2202", + "code": "181" + }, + "둔내": { + "tel": "033-812-2203", + "code": "182" + }, + "면온": { + "tel": "033-812-2204", + "code": "183" + }, + "대관령": { + "tel": "033-812-2208", + "code": "184" + }, + "평창": { + "tel": "033-812-2205", + "code": "185" + }, + "속사": { + "tel": "033-812-2206", + "code": "186" + }, + "진부": { + "tel": "033-812-2207", + "code": "187" + }, + "서충주": { + "tel": "043-756-2307", + "code": "188" + }, + "강릉": { + "tel": "033-812-2216", + "code": "189" + }, + "동서울": { + "tel": "031-327-2132", + "code": "190" + }, + "경기광주": { + "tel": "031-327-2133", + "code": "191" + }, + "곤지암": { + "tel": "031-327-2134", + "code": "192" + }, + "일죽": { + "tel": "031-327-2150", + "code": "193" + }, + "서이천": { + "tel": "031-327-2135", + "code": "194" + }, + "대소": { + "tel": "043-756-2314", + "code": "195" + }, + "진천": { + "tel": "043-756-2315", + "code": "196" + }, + "증평": { + "tel": "043-756-2316", + "code": "197" + }, + "서청주": { + "tel": "043-756-2318", + "code": "198" + }, + "오창": { + "tel": "043-756-2317", + "code": "199" + }, + "금산": { + "tel": "041-411-2405", + "code": "200" + }, + "칠곡": { + "tel": "053-717-2795", + "code": "201" + }, + "다부": { + "tel": "054-706-2740", + "code": "202" + }, + "가산": { + "tel": "054-706-2739", + "code": "203" + }, + "추부": { + "tel": "041-411-2406", + "code": "204" + }, + "군위": { + "tel": "054-706-2710", + "code": "205" + }, + "의성": { + "tel": "054-706-2736", + "code": "206" + }, + "남안동": { + "tel": "054-706-2725", + "code": "207" + }, + "서안동": { + "tel": "054-706-2726", + "code": "208" + }, + "풍산": { + "tel": "054-853-9925", + "code": "208" + }, + "예천": { + "tel": "054-706-2733", + "code": "209" + }, + "영주": { + "tel": "054-706-2729", + "code": "210" + }, + "풍기": { + "tel": "054-706-2728", + "code": "211" + }, + "북수원": { + "tel": "031-327-2109", + "code": "212" + }, + "부곡": { + "tel": "031-327-2110", + "code": "213" + }, + "동군포": { + "tel": "031-327-2111", + "code": "214" + }, + "군포": { + "tel": "031-327-2112", + "code": "215" + }, + "횡성": { + "tel": "033-812-2210", + "code": "216" + }, + "북원주": { + "tel": "033-812-2209", + "code": "217" + }, + "남원주": { + "tel": "033-812-2230", + "code": "218" + }, + "신림": { + "tel": "033-812-2231", + "code": "219" + }, + "생초": { + "tel": "055-711-2819", + "code": "221" + }, + "산청": { + "tel": "055-711-2817", + "code": "222" + }, + "단성": { + "tel": "055-711-2818", + "code": "223" + }, + "함양": { + "tel": "055-711-2847", + "code": "224" + }, + "서진주": { + "tel": "055-711-2825", + "code": "225" + }, + "군산": { + "tel": "063-712-2504", + "code": "227" + }, + "동군산": { + "tel": "063-712-2503", + "code": "228" + }, + "북천안": { + "tel": "041-411-2428", + "code": "229" + }, + "팔공산": { + "tel": "053-717-2794", + "code": "230" + }, + "화원옥포": { + "tel": "053-717-2796", + "code": "231" + }, + "화원": { + "tel": "053-614-8800", + "code": "231" + }, + "달성": { + "tel": "053-717-2791", + "code": "232" + }, + "현풍": { + "tel": "053-717-2792", + "code": "233" + }, + "서천": { + "tel": "041-411-2421", + "code": "234" + }, + "창녕": { + "tel": "055-711-2830", + "code": "235" + }, + "영산": { + "tel": "055-711-2829", + "code": "236" + }, + "남지": { + "tel": "055-711-2828", + "code": "237" + }, + "칠서": { + "tel": "055-711-2843", + "code": "238" + }, + "칠원": { + "tel": "055-711-2844", + "code": "239" + }, + "제천": { + "tel": "043-756-2310", + "code": "240" + }, + "동고령": { + "tel": "054-706-2705", + "code": "241" + }, + "성산": { + "tel": "054-954-0529", + "code": "241" + }, + "고령": { + "tel": "054-706-2706", + "code": "242" + }, + "해인사": { + "tel": "055-711-2848", + "code": "243" + }, + "서부산": { + "tel": "055-711-2807", + "code": "244" + }, + "장유": { + "tel": "055-711-2811", + "code": "245" + }, + "가락": { + "tel": "051-793-2880", + "code": "246" + }, + "남원": { + "tel": "063-712-2512", + "code": "247" + }, + "순창": { + "tel": "063-712-2517", + "code": "248" + }, + "담양": { + "tel": "061-882-2614", + "code": "249" + }, + "남양산": { + "tel": "055-711-2820", + "code": "250" + }, + "물금": { + "tel": "055-711-2821", + "code": "251" + }, + "대동": { + "tel": "055-711-2809", + "code": "252" + }, + "서서울": { + "tel": "031-327-2103", + "code": "253" + }, + "군자": { + "tel": "031-327-2105", + "code": "254" + }, + "서안산": { + "tel": "031-327-2106", + "code": "255" + }, + "안산": { + "tel": "031-327-2107", + "code": "256" + }, + "홍성": { + "tel": "041-411-2430", + "code": "257" + }, + "광천": { + "tel": "041-411-2412", + "code": "258" + }, + "대천": { + "tel": "041-411-2413", + "code": "259" + }, + "춘장대": { + "tel": "041-411-2420", + "code": "260" + }, + "동광주": { + "tel": "062-719-2692", + "code": "261" + }, + "창평": { + "tel": "061-882-2615", + "code": "262" + }, + "옥과": { + "tel": "061-882-2604", + "code": "263" + }, + "동남원": { + "tel": "063-712-2509", + "code": "264" + }, + "곡성": { + "tel": "061-882-2602", + "code": "265" + }, + "석곡": { + "tel": "061-882-2603", + "code": "266" + }, + "주암송광사": { + "tel": "061-882-2627", + "code": "267" + }, + "송광사": { + "tel": "061-882-2627", + "code": "267" + }, + "승주": { + "tel": "061-882-2625", + "code": "268" + }, + "지리산": { + "tel": "063-712-2511", + "code": "269" + }, + "서순천": { + "tel": "061-882-2623", + "code": "270" + }, + "순천": { + "tel": "061-882-2624", + "code": "271" + }, + "광양": { + "tel": "061-882-2606", + "code": "272" + }, + "동광양": { + "tel": "061-882-2608", + "code": "273" + }, + "가조": { + "tel": "055-711-2801", + "code": "274" + }, + "옥곡": { + "tel": "061-882-2607", + "code": "275" + }, + "진월": { + "tel": "061-882-2609", + "code": "276" + }, + "하동": { + "tel": "055-711-2837", + "code": "277" + }, + "진교": { + "tel": "055-711-2838", + "code": "278" + }, + "곤양": { + "tel": "055-711-2814", + "code": "279" + }, + "거창": { + "tel": "055-711-2802", + "code": "280" + }, + "매송": { + "tel": "031-327-2114", + "code": "281" + }, + "비봉": { + "tel": "031-327-2115", + "code": "282" + }, + "발안": { + "tel": "031-327-2116", + "code": "283" + }, + "남양양": { + "tel": "033-812-2214", + "code": "284" + }, + "서평택": { + "tel": "031-327-2117", + "code": "285" + }, + "송악": { + "tel": "041-411-2410", + "code": "287" + }, + "당진": { + "tel": "041-411-2409", + "code": "288" + }, + "마산": { + "tel": "055-711-2832", + "code": "289" + }, + "축동": { + "tel": "055-711-2816", + "code": "290" + }, + "사천": { + "tel": "055-711-2815", + "code": "291" + }, + "진주": { + "tel": "055-711-2823", + "code": "292" + }, + "문산": { + "tel": "055-711-2824", + "code": "293" + }, + "북창원": { + "tel": "055-711-2833", + "code": "294" + }, + "진성": { + "tel": "055-711-2827", + "code": "295" + }, + "지수": { + "tel": "055-711-2826", + "code": "296" + }, + "동충주": { + "tel": "043-756-2309", + "code": "297" + }, + "서산": { + "tel": "041-411-2417", + "code": "298" + }, + "해미": { + "tel": "041-411-2419", + "code": "299" + }, + "남여주": { + "tel": "031-327-2144", + "code": "500" + }, + "서김제": { + "tel": "063-712-2507", + "code": "501" + }, + "부안": { + "tel": "063-712-2515", + "code": "502" + }, + "줄포": { + "tel": "063-712-2516", + "code": "503" + }, + "선운산": { + "tel": "063-712-2502", + "code": "504" + }, + "고창": { + "tel": "063-712-2501", + "code": "505" + }, + "영광": { + "tel": "061-882-2629", + "code": "506" + }, + "함평": { + "tel": "061-882-2637", + "code": "507" + }, + "무안": { + "tel": "061-882-2616", + "code": "508" + }, + "목포": { + "tel": "061-882-2618", + "code": "509" + }, + "양양": { + "tel": "033-812-2228", + "code": "510" + }, + "무창포": { + "tel": "041-411-2411", + "code": "511" + }, + "연화산": { + "tel": "055-711-2805", + "code": "512" + }, + "고성": { + "tel": "055-711-2804", + "code": "513" + }, + "동고성": { + "tel": "055-711-2803", + "code": "514" + }, + "북통영": { + "tel": "055-711-2835", + "code": "515" + }, + "통영": { + "tel": "055-711-2836", + "code": "516" + }, + "양평": { + "tel": "031-327-2147", + "code": "517" + }, + "칠곡물류": { + "tel": "054-706-2742", + "code": "518" + }, + "남대구": { + "tel": "053-717-2790", + "code": "519" + }, + "평택": { + "tel": "031-327-2122", + "code": "520" + }, + "단양": { + "tel": "043-756-2313", + "code": "521" + }, + "북단양": { + "tel": "043-756-2312", + "code": "522" + }, + "남제천": { + "tel": "043-756-2311", + "code": "523" + }, + "청통와촌": { + "tel": "054-706-2732", + "code": "524" + }, + "북영천": { + "tel": "054-706-2730", + "code": "525" + }, + "서포항": { + "tel": "054-706-2745", + "code": "526" + }, + "포항": { + "tel": "054-706-2743", + "code": "527" + }, + "기흥동탄": { + "tel": "031-327-2127", + "code": "528" + }, + "노포": { + "tel": "051-793-2883", + "code": "529" + }, + "내장산": { + "tel": "063-712-2530", + "code": "530" + }, + "문의": { + "tel": "043-756-2319", + "code": "531" + }, + "회인": { + "tel": "043-756-2320", + "code": "532" + }, + "보은": { + "tel": "043-756-2321", + "code": "533" + }, + "속리산": { + "tel": "043-756-2322", + "code": "534" + }, + "화서": { + "tel": "054-706-2721", + "code": "535" + }, + "남상주": { + "tel": "054-706-2716", + "code": "536" + }, + "남김천": { + "tel": "054-706-2713", + "code": "537" + }, + "성주": { + "tel": "054-706-2722", + "code": "538" + }, + "남성주": { + "tel": "054-706-2723", + "code": "539" + }, + "청북": { + "tel": "031-327-2118", + "code": "540" + }, + "송탄": { + "tel": "031-327-2119", + "code": "541" + }, + "서안성": { + "tel": "031-327-2120", + "code": "542" + }, + "남안성": { + "tel": "031-327-2121", + "code": "543" + }, + "완주": { + "tel": "063-712-2521", + "code": "544" + }, + "소양": { + "tel": "063-712-2520", + "code": "545" + }, + "진안": { + "tel": "063-712-2532", + "code": "546" + }, + "북무안": { + "tel": "061-454-7202", + "code": "548" + }, + "현경": { + "tel": "061-454-7202", + "code": "548" + }, + "동함평": { + "tel": "061-882-2636", + "code": "549" + }, + "문평": { + "tel": "061-882-2612", + "code": "550" + }, + "나주": { + "tel": "061-882-2611", + "code": "551" + }, + "서광산": { + "tel": "062-719-2691", + "code": "552" + }, + "동광산": { + "tel": "062-719-2690", + "code": "553" + }, + "동홍천": { + "tel": "033-812-2213", + "code": "554" + }, + "북진천": { + "tel": "043-756-2305", + "code": "555" + }, + "동순천": { + "tel": "061-882-2605", + "code": "556" + }, + "황전": { + "tel": "061-882-2628", + "code": "557" + }, + "구례화엄사": { + "tel": "061-882-2610", + "code": "558" + }, + "서남원": { + "tel": "063-712-2510", + "code": "559" + }, + "감곡": { + "tel": "043-756-2300", + "code": "560" + }, + "북충주": { + "tel": "043-756-2301", + "code": "561" + }, + "중앙탑": { + "tel": "준비중", + "code": "762" + }, + "충주": { + "tel": "043-756-2302", + "code": "562" + }, + "괴산": { + "tel": "043-756-2303", + "code": "563" + }, + "연풍": { + "tel": "043-756-2304", + "code": "564" + }, + "문경새재": { + "tel": "054-706-2715", + "code": "565" + }, + "점촌함창": { + "tel": "054-706-2720", + "code": "566" + }, + "남고창": { + "tel": "063-712-2500", + "code": "567" + }, + "장성물류": { + "tel": "061-882-2633", + "code": "568" + }, + "북광주": { + "tel": "061-882-2613", + "code": "569" + }, + "북상주": { + "tel": "054-706-2717", + "code": "570" + }, + "면천": { + "tel": "041-411-2408", + "code": "571" + }, + "고덕": { + "tel": "041-411-2423", + "code": "572" + }, + "예산수덕사": { + "tel": "041-411-2425", + "code": "573" + }, + "신양": { + "tel": "041-411-2424", + "code": "574" + }, + "유구": { + "tel": "041-411-2404", + "code": "575" + }, + "마곡사": { + "tel": "041-411-2401", + "code": "576" + }, + "공주": { + "tel": "041-411-2402", + "code": "577" + }, + "서세종": { + "tel": "044-410-2480", + "code": "578" + }, + "남세종": { + "tel": "044-410-2481", + "code": "579" + }, + "북강릉": { + "tel": "033-812-2215", + "code": "580" + }, + "옥계": { + "tel": "033-812-2218", + "code": "581" + }, + "망상": { + "tel": "033-812-2219", + "code": "582" + }, + "동해": { + "tel": "033-812-2220", + "code": "583" + }, + "남강릉": { + "tel": "033-812-2217", + "code": "584" + }, + "하조대": { + "tel": "033-812-2229", + "code": "585" + }, + "북남원": { + "tel": "063-712-2508", + "code": "586" + }, + "오수": { + "tel": "063-712-2525", + "code": "587" + }, + "임실": { + "tel": "063-712-2524", + "code": "588" + }, + "상관": { + "tel": "063-712-2519", + "code": "589" + }, + "동전주": { + "tel": "063-712-2528", + "code": "590" + }, + "동서천": { + "tel": "041-411-2422", + "code": "591" + }, + "서부여": { + "tel": "041-411-2416", + "code": "592" + }, + "부여": { + "tel": "041-411-2414", + "code": "593" + }, + "청양": { + "tel": "041-411-2429", + "code": "594" + }, + "서공주": { + "tel": "041-411-2403", + "code": "595" + }, + "가락2": { + "tel": "055-329-3621", + "code": "596" + }, + "서여주": { + "tel": "031-327-2145", + "code": "597" + }, + "북여주": { + "tel": "031-327-2146", + "code": "598" + }, + "양촌": { + "tel": "042-841-2504", + "code": "599" + }, + "남천안": { + "tel": "041-850-6763", + "code": "601" + }, + "풍세": { + "tel": "041-850-6768", + "code": "1000" + }, + "풍세상": { + "tel": "041-850-6768", + "code": "602" + }, + "정안": { + "tel": "041-850-6786", + "code": "603" + }, + "남공주": { + "tel": "041-850-6805", + "code": "604" + }, + "탄천": { + "tel": "041-850-6808", + "code": "605" + }, + "서논산": { + "tel": "041-850-6827", + "code": "606" + }, + "연무": { + "tel": "041-850-6859", + "code": "607" + }, + "남논산하": { + "tel": "041-850-6871", + "code": "608" + }, + "남논산": { + "tel": "041-850-6871", + "code": "1000" + }, + "낙동2": { + "tel": "1877-0111", + "code": "610" + }, + "도개": { + "tel": "041-850-6793", + "code": "611" + }, + "풍세하": { + "tel": "041-850-6768", + "code": "612" + }, + "남풍세": { + "tel": "041-850-6793", + "code": "613" + }, + "서군위": { + "tel": "041-850-6793", + "code": "614" + }, + "동군위": { + "tel": "041-850-6793", + "code": "615" + }, + "신녕": { + "tel": "1877-0111", + "code": "616" + }, + "남논산상": { + "tel": "041-850-6871", + "code": "618" + }, + "영천JC": { + "tel": "1877-0111", + "code": "620" + }, + "동대구": { + "tel": "053-961-0302", + "code": "621" + }, + "대구": { + "tel": "053-791-5388", + "code": "622" + }, + "수성": { + "tel": "053-791-7330", + "code": "623" + }, + "청도": { + "tel": "054-373-5680", + "code": "624" + }, + "밀양": { + "tel": "055-355-0924", + "code": "625" + }, + "남밀양": { + "tel": "055-355-5451", + "code": "626" + }, + "삼랑진": { + "tel": "055-351-0502", + "code": "627" + }, + "상동": { + "tel": "055-323-1690", + "code": "628" + }, + "김해부산": { + "tel": "055-323-3805", + "code": "629" + }, + "울산": { + "tel": "052-254-8800", + "code": "641" + }, + "문수": { + "tel": "052-277-7501", + "code": "642" + }, + "청량": { + "tel": "052-260-6501", + "code": "643" + }, + "온양": { + "tel": "052-237-1170", + "code": "644" + }, + "장안": { + "tel": "051-727-3520", + "code": "645" + }, + "기장일광": { + "tel": "051-721-2554", + "code": "646" + }, + "해운대송정": { + "tel": "051-722-2514", + "code": "647" + }, + "해운대": { + "tel": "051-722-2514", + "code": "648" + }, + "군위출구JC": { + "tel": "1877-0111", + "code": "650" + }, + "남양주": { + "tel": "031-576-3545", + "code": "651" + }, + "화도": { + "tel": "031-559-2731", + "code": "652" + }, + "서종": { + "tel": "031-771-6712", + "code": "653" + }, + "설악": { + "tel": "031-584-2427", + "code": "654" + }, + "강촌": { + "tel": "033-264-1057", + "code": "655" + }, + "남춘천": { + "tel": "033-261-1662", + "code": "656" + }, + "동산": { + "tel": "033-261-1093", + "code": "657" + }, + "조양": { + "tel": "033-261-1093", + "code": "658" + }, + "초월": { + "tel": "070-8031-5376", + "code": "660" + }, + "동곤지암": { + "tel": "070-8032-4140", + "code": "661" + }, + "흥천이포": { + "tel": "031-775-2665", + "code": "662" + }, + "대신": { + "tel": "031-775-7306", + "code": "663" + }, + "동여주": { + "tel": "070-8032-4140", + "code": "664" + }, + "동양평": { + "tel": "070-8031-5399", + "code": "665" + }, + "서원주": { + "tel": "033-731-8068", + "code": "666" + }, + "경기광주JC": { + "tel": "070-8031-5376", + "code": "667" + }, + "신평JC": { + "tel": "033-731-8068", + "code": "668" + }, + "원주JC": { + "tel": "033-731-8068", + "code": "669" + }, + "봉담": { + "tel": "070-8031-4294", + "code": "671" + }, + "정남": { + "tel": "070-8031-4600", + "code": "672" + }, + "북오산": { + "tel": "070-8031-4227", + "code": "673" + }, + "동탄": { + "tel": "070-8031-5254", + "code": "836" + }, + "서오산": { + "tel": "070-8031-4226", + "code": "675" + }, + "향남": { + "tel": "070-8031-4228", + "code": "676" + }, + "북평택": { + "tel": "070-8031-4229", + "code": "677" + }, + "화산출구JC": { + "tel": "1877-0111", + "code": "680" + }, + "조암": { + "tel": "031-494-0393", + "code": "682" + }, + "송산마도": { + "tel": "031-494-0427", + "code": "683" + }, + "남안산": { + "tel": "031-494-0436", + "code": "684" + }, + "서시흥": { + "tel": "031-494-0430", + "code": "685" + }, + "동영천": { + "tel": "054-536-9159", + "code": "686" + }, + "금곡": { + "tel": "070-8031-5010", + "code": "691" + }, + "동안산당수": { + "tel": "070-8031-5025", + "code": "692" + }, + "남군포": { + "tel": "070-8031-5060", + "code": "693" + }, + "동시흥": { + "tel": "070-8031-5078", + "code": "694" + }, + "목감": { + "tel": "070-8031-5078", + "code": "694" + }, + "남광명": { + "tel": "070-8031-5100", + "code": "695" + }, + "남진례": { + "tel": "070-5143-6377", + "code": "696" + }, + "대청": { + "tel": "070-5143-6375", + "code": "697" + }, + "진해": { + "tel": "070-5143-6371", + "code": "698" + }, + "진해본선": { + "tel": "070-5143-6371", + "code": "699" + }, + "북양양": { + "tel": "033-812-2227", + "code": "701" + }, + "속초": { + "tel": "033-812-2223", + "code": "702" + }, + "삼척": { + "tel": "033-812-2221", + "code": "703" + }, + "근덕": { + "tel": "033-812-2222", + "code": "704" + }, + "남이천": { + "tel": "031-327-2149", + "code": "705" + }, + "남경주": { + "tel": "054-706-2704", + "code": "706" + }, + "남포항": { + "tel": "054-706-2744", + "code": "707" + }, + "동경주": { + "tel": "054-706-2703", + "code": "708" + }, + "삽교": { + "tel": "없음", + "code": "709" + }, + "동상주": { + "tel": "054-706-2718", + "code": "712" + }, + "서의성": { + "tel": "054-706-2734", + "code": "713" + }, + "북의성": { + "tel": "054-706-2735", + "code": "714" + }, + "동안동": { + "tel": "054-706-2724", + "code": "715" + }, + "청송": { + "tel": "054-706-2738", + "code": "716" + }, + "활천": { + "tel": "052-701-2890", + "code": "722" + }, + "임고": { + "tel": "054-336-9600", + "code": "725" + }, + "신둔": { + "tel": "031-636-7095", + "code": "729" + }, + "유천": { + "tel": "053-591-2504", + "code": "730" + }, + "북현풍": { + "tel": "053-611-4532", + "code": "731" + }, + "행담도": { + "tel": "041-357-0878", + "code": "732" + }, + "옥산": { + "tel": "043-232-0554", + "code": "733" + }, + "동둔내": { + "tel": "033-334-3005", + "code": "734" + }, + "남양평": { + "tel": "031-771-0390", + "code": "735" + }, + "삼성": { + "tel": "043-882-2504", + "code": "736" + }, + "평택고덕": { + "tel": "031-327-2123", + "code": "741" + }, + "기장일광입구": { + "tel": "051-721-2554", + "code": "746" + }, + "범서": { + "tel": "052-701-2891", + "code": "747" + }, + "동청송영양": { + "tel": "054-706-2737", + "code": "748" + }, + "영덕": { + "tel": "054-706-2727", + "code": "749" + }, + "기장": { + "tel": "051-721-2554", + "code": "751" + }, + "기장철마": { + "tel": "051-793-2850", + "code": "752" + }, + "금정": { + "tel": "051-793-2884", + "code": "753" + }, + "광재": { + "tel": "055-711-2810", + "code": "755" + }, + "진영": { + "tel": "055-711-2813", + "code": "757" + }, + "내촌": { + "tel": "033-812-2224", + "code": "765" + }, + "인제": { + "tel": "033-812-2225", + "code": "766" + }, + "구병산": { + "tel": "043-544-0141", + "code": "767" + }, + "서양양": { + "tel": "033-812-2226", + "code": "768" + }, + "서오창동JC": { + "tel": "070-4219-5716", + "code": "840" + }, + "서오창서JC": { + "tel": "070-4219-5716", + "code": "850" + }, + "서오창": { + "tel": "070-4219-5716", + "code": "851" + }, + "북중랑": { + "tel": "없음", + "code": "860" + }, + "갈매동구릉": { + "tel": "070-4220-6490", + "code": "861" + }, + "동구릉": { + "tel": "070-4220-6490", + "code": "861" + }, + "남별내": { + "tel": "070-4173-7383", + "code": "862" + }, + "동의정부": { + "tel": "070-4220-6524", + "code": "863" + }, + "민락": { + "tel": "070-4220-6549", + "code": "864" + }, + "소흘": { + "tel": "070-4220-6530", + "code": "865" + }, + "선단": { + "tel": "070-4220-6569", + "code": "866" + }, + "포천": { + "tel": "070-4220-6573", + "code": "867" + }, + "신북": { + "tel": "070-4220-6583", + "code": "869" + }, + "옥정": { + "tel": "070-4220-6590", + "code": "870" + }, + "양주": { + "tel": "070-4220-6590", + "code": "879" + }, + "청라원창": { + "tel": "032-292-7236", + "code": "886" + }, + "남청라": { + "tel": "032-292-7150", + "code": "887" + }, + "북청라": { + "tel": "032-292-7249", + "code": "889" + }, + "검단양촌": { + "tel": "032-292-7198", + "code": "890" + }, + "대곶": { + "tel": "032-292-7211", + "code": "891" + }, + "서김포통진": { + "tel": "032-292-7224", + "code": "892" + }, + "서영암": { + "tel": "061-882-2630", + "code": "981" + }, + "강진무위사": { + "tel": "061-882-2600", + "code": "982" + }, + "강진": { + "tel": "061-434-9912", + "code": "982" + }, + "장흥": { + "tel": "061-882-2635", + "code": "983" + }, + "보성": { + "tel": "061-882-2620", + "code": "984" + }, + "벌교": { + "tel": "061-882-2621", + "code": "985" + }, + "고흥": { + "tel": "061-882-2601", + "code": "986" + }, + "남순천": { + "tel": "061-882-2622", + "code": "987" + }, + "무안공항": { + "tel": "061-882-2619", + "code": "547" + }, + "한림": { + "tel": "055-711-2800", + "code": "756" + }, + "배내골": { + "tel": "055-711-2849", + "code": "771" + }, + "남봉담": { + "tel": "", + "code": "801" + }, + "청요서": { + "tel": "", + "code": "812" + }, + "청요동": { + "tel": "", + "code": "802" + }, + "남비봉팔탄": { + "tel": "", + "code": "803" + }, + "화성": { + "tel": "", + "code": "804" + }, + "마도": { + "tel": "", + "code": "805" + }, + "마도서": { + "tel": "", + "code": "806" + }, + "마도동": { + "tel": "", + "code": "816" + }, + "장안본선": { + "tel": "", + "code": "681" + }, + "도척": { + "tel": "070-8031-5272", + "code": "831" + }, + "포곡": { + "tel": "070-8031-5264", + "code": "832" + }, + "서용인": { + "tel": "070-8031-5260", + "code": "835" + }, + "북달성": { + "tel": "053-717-2785", + "code": "772" + }, + "북다사": { + "tel": "053-717-2786", + "code": "773" + }, + "남칠곡": { + "tel": "053-706-2746", + "code": "774" + }, + "지천": { + "tel": "054-706-2747", + "code": "058" + }, + "동명동호": { + "tel": "053-706-2748", + "code": "775" + }, + "연경": { + "tel": "053-717-2787", + "code": "776" + }, + "파군재": { + "tel": "053-717-2788", + "code": "777" + }, + "둔산": { + "tel": "053-717-2789", + "code": "778" + }, + "율암": { + "tel": "053-717-2783", + "code": "779" + }, + "남사진위": { + "tel": "031-327-2152", + "code": "742" + }, + "남광산": { + "tel": "061-882-2693", + "code": "718" + }, + "남장성": { + "tel": "061-882-2638", + "code": "719" + }, + "두물머리": { + "tel": "031-327-2153", + "code": "763" + }, + "조안": { + "tel": "031-327-2153", + "code": "764" + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/main/resources/intent-definitions.yml b/chatbotApi-chatbot2.0-agent/src/main/resources/intent-definitions.yml new file mode 100644 index 0000000..31e6a93 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/main/resources/intent-definitions.yml @@ -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": "조회할 노선명을 알려주세요. 예: 경부선, 영동선" + } + } + } + } +} \ No newline at end of file diff --git a/chatbotApi-chatbot2.0-agent/src/test/java/com/ex/chatbotapi/ChatbotapiApplicationTests.java b/chatbotApi-chatbot2.0-agent/src/test/java/com/ex/chatbotapi/ChatbotapiApplicationTests.java new file mode 100644 index 0000000..25b89a7 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/test/java/com/ex/chatbotapi/ChatbotapiApplicationTests.java @@ -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() { + } + +} diff --git a/chatbotApi-chatbot2.0-agent/src/test/java/com/ex/chatbotapi/domain/agent/service/IntentParamValidatorTest.java b/chatbotApi-chatbot2.0-agent/src/test/java/com/ex/chatbotapi/domain/agent/service/IntentParamValidatorTest.java new file mode 100644 index 0000000..cc59ded --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/test/java/com/ex/chatbotapi/domain/agent/service/IntentParamValidatorTest.java @@ -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 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 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", "신갈"); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/test/java/com/ex/chatbotapi/domain/agent/service/ToolDefinitionServiceTest.java b/chatbotApi-chatbot2.0-agent/src/test/java/com/ex/chatbotapi/domain/agent/service/ToolDefinitionServiceTest.java new file mode 100644 index 0000000..96dd527 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/test/java/com/ex/chatbotapi/domain/agent/service/ToolDefinitionServiceTest.java @@ -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> tools = toolDefinitionService.listToolDefinitions(); + + assertThat(tools).hasSize(1); + @SuppressWarnings("unchecked") + Map function = (Map) 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; + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/test/java/com/ex/chatbotapi/domain/agent/service/ToolExecutionServiceTest.java b/chatbotApi-chatbot2.0-agent/src/test/java/com/ex/chatbotapi/domain/agent/service/ToolExecutionServiceTest.java new file mode 100644 index 0000000..c8c432a --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/test/java/com/ex/chatbotapi/domain/agent/service/ToolExecutionServiceTest.java @@ -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 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 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"); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/test/java/com/ex/chatbotapi/domain/fare/service/FareAmountHelperTest.java b/chatbotApi-chatbot2.0-agent/src/test/java/com/ex/chatbotapi/domain/fare/service/FareAmountHelperTest.java new file mode 100644 index 0000000..dba7c90 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/test/java/com/ex/chatbotapi/domain/fare/service/FareAmountHelperTest.java @@ -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 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 breakdown = (Map) 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> vehicles = (List>) breakdown.get("vehicles"); + assertThat(vehicles).hasSize(6); + assertThat(vehicles.get(0).get("typeLabel")).isEqualTo("1종"); + assertThat(vehicles.get(5).get("compact")).isEqualTo(true); + } +} diff --git a/chatbotApi-chatbot2.0-agent/src/test/java/com/ex/chatbotapi/domain/rag/dto/RagServerResponseTest.java b/chatbotApi-chatbot2.0-agent/src/test/java/com/ex/chatbotapi/domain/rag/dto/RagServerResponseTest.java new file mode 100644 index 0000000..595ce41 --- /dev/null +++ b/chatbotApi-chatbot2.0-agent/src/test/java/com/ex/chatbotapi/domain/rag/dto/RagServerResponseTest.java @@ -0,0 +1,35 @@ +package com.ex.chatbotapi.domain.rag.dto; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class RagServerResponseTest { + + @Test + void shouldDeserializeFaqUrlsAndReferencesFromRagServerResponse() throws Exception { + String json = "{" + + "\"answer\":\"하이패스 FAQ 안내입니다.\"," + + "\"matched_questions\":[\"하이패스 단말기 등록은 어떻게 하나요?\"]," + + "\"scores\":[0.95]," + + "\"num_references\":1," + + "\"faq_urls\":[\"https://www.hipass.co.kr/board/FaqDetail.do?ser_no=186\"]," + + "\"references\":[{\"question\":\"하이패스 단말기 등록은 어떻게 하나요?\"," + + "\"source\":\"hipass_faq\"," + + "\"url\":\"https://www.hipass.co.kr/board/FaqDetail.do?ser_no=186\"}]" + + "}"; + + RagServerResponse response = new ObjectMapper().readValue(json, RagServerResponse.class); + + assertThat(response.getMatchedQuestions()).containsExactly("하이패스 단말기 등록은 어떻게 하나요?"); + assertThat(response.getFaqUrls()).containsExactly("https://www.hipass.co.kr/board/FaqDetail.do?ser_no=186"); + assertThat(response.getReferences()).hasSize(1); + + Map reference = response.getReferences().get(0); + assertThat(reference.get("source")).isEqualTo("hipass_faq"); + assertThat(reference.get("url")).isEqualTo("https://www.hipass.co.kr/board/FaqDetail.do?ser_no=186"); + } +} diff --git a/chatbotApp-chatbot2.0-agent.zip b/chatbotApp-chatbot2.0-agent.zip new file mode 100644 index 0000000..c0ad0ce Binary files /dev/null and b/chatbotApp-chatbot2.0-agent.zip differ diff --git a/chatbotApp-chatbot2.0-agent/README.md b/chatbotApp-chatbot2.0-agent/README.md new file mode 100644 index 0000000..9c79f1c --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/README.md @@ -0,0 +1,109 @@ +# chatbotApp — 카카오 인앱·브라우저 단독 챗봇 UI (Next.js + SSE) + +`chatbotAdmin`의 챗봇 테스트 화면과 **별개**인, 고속도로 이용객 대상 **단독 웹 챗봇**입니다. + +| 구분 | 경로 | 기술 | +|------|------|------| +| **chatbotApp (기존)** | `/chatbot/app/` | Next.js 14 · 카카오톡 스타일 · SSE | +| **chatbotApp (AI 모드)** | `/chatbot/ai/` | Next.js 14 · 정부24 AI 스타일 · SSE | +| **chatbotApp (배너)** | `/chatbot/banner/` | 검색창 + 드롭다운 · iframe/embed.js 임베드 | +| **chatbotApp (샘플)** | `/chatbot/sample/` | 국민참여로 UI + banner iframe 데모 | +| chatbotAdmin ChatPage | `/chatbot/admin/` | Admin RAG 디버그 | + +## 운영 URL + +``` +https://chatbot.exdev.co.kr/chatbot/app/ ← 기존 UI +https://chatbot.exdev.co.kr/chatbot/ai/ ← AI 모드 UI +https://chatbot.exdev.co.kr/chatbot/banner/ ← 배너 검색 (임베드) +https://chatbot.exdev.co.kr/chatbot/sample/ ← 국민참여로 샘플 +``` + +## 아키텍처 + +```mermaid +flowchart TD + APP[브라우저/카카오 인앱] -->|/chatbot/ask/stream SSE, /chatbot/ask JSON| SK[kakaoChatbotSkill /web/ask +/stream] + SK --> RAG[RAG :28012 /agent/chat] + RAG --> OUT([answer + 카드 + faqUrls]) +``` + +- **웹 입력 전용 경로**: chatbotApp 질의는 kakaoChatbotSkill의 `/web/ask` 또는 `/web/ask/stream`을 거쳐 RAG Agent(`/agent/chat`)로 전달됩니다. +- **속도**: SSE는 kakao-skill에서 전체 답변 완료 후 `done` 이벤트로 전달 (카카오 callback과 유사). +- **안정성**: JSON API도 유지해 스트림 실패 시 일반 응답을 받을 수 있습니다. +- **FAQ/세션**: `/chatbot/faq/*`, `/chatbot/session/*`는 웹 UI 보조 API입니다. + +### botId · 세션 정책 (현재) + +- 웹앱은 `sessionStorage`에 저장한 랜덤 `botId`(`web-...`, `src/lib/session.ts`)를 사용하며, **대화마다 새 botId로 시작하는 무상태 정책**입니다. +- 카카오톡과 **세션을 연계하지 않으며**, 웹 진입 시 **과거 대화를 화면에 복원하지 않습니다**(`useChatSession`은 빈 상태로 시작, `/web/ask`만 호출). +- 서버(MongoDB `chat_history`)에는 botId별 이력이 남지만, botId가 매번 새로 발급되므로 사실상 새 세션입니다. 정책 배경·향후 세션 연계(서명 토큰) 옵션: [`../SYSTEM_ARCHITECTURE.md`](../SYSTEM_ARCHITECTURE.md), [`../IMPROVEMENTS.md`](../IMPROVEMENTS.md). + +## 로컬 단독 실행 (nginx :8089) + +> **포트:** `8089` = chatbotApp 전용. Caddy HTTP는 `9089` (`CADDY_HTTP_PORT`). +> GoldenChart 로그인이 보이면 `docker stop ex-chatbot-caddy` 후 `./start.sh` 재실행. + +```bash +cd chatbotApp +./start.sh +# → http://localhost:8089/chatbot/app/ +``` + +RAG API(`:28012`)가 기동되어 있어야 질의·SSE가 동작합니다. + +로컬 UI + **원격 API**(기본): `./start.sh` — nginx가 `/chatbot/*`를 `https://chatbot.exdev.co.kr`로 프록시합니다. 로컬 LLM/RAG는 필요 없습니다. + +로컬 RAG로 테스트하려면: + +```bash +CHATBOT_API_UPSTREAM=http://host.docker.internal:28012/chatbot/ \ +CHATBOT_API_HOST=host.docker.internal \ +./start.sh +``` + +## 개발 + +```bash +cd chatbotApp/web +npm install +npm run dev # http://localhost:3000 — /chatbot/* 는 exdev.co.kr 로 프록시 (next.config rewrites) +``` + +## 배포 빌드 + +```bash +cd chatbotApp +chmod +x build.sh +./build.sh # → public/ (VERSION 3.0.0) +``` + +프로젝트 루트에서 RAG 반영: + +```bash +./scripts/reload-chatbot-app.sh +# 또는 +./remote-start-chatbot.sh +``` + +## API + +| Method | Path | 설명 | +|--------|------|------| +| POST | `/chatbot/ask/stream` | SSE 스트리밍 질의 (웹앱 기본) | +| POST | `/chatbot/ask` | JSON 응답 | +| GET | `/chatbot/faq/popular` | FAQ | +| POST | `/chatbot/session/start` | 세션 추적 | + +## 파일 구조 + +``` +chatbotApp/ +├── web/ # Next.js 소스 +│ ├── src/ +│ └── scripts/export-public.mjs +├── public/ # 빌드 산출물 (배포) +├── build.sh +├── nginx/ +└── docker-compose.yml +``` diff --git a/chatbotApp-chatbot2.0-agent/build.sh b/chatbotApp-chatbot2.0-agent/build.sh new file mode 100755 index 0000000..12d213e --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/build.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# chatbotApp Next.js 빌드 → public/ + public-ai/ + public-banner/ +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WEB_DIR="${SCRIPT_DIR}/web" +EMBED_SRC="${SCRIPT_DIR}/embed/chatbot-banner.js" + +build_variant() { + local base_path="$1" + local ui_mode="$2" + local export_dir="$3" + + echo "[build] NEXT_PUBLIC_BASE_PATH=${base_path} NEXT_PUBLIC_UI_MODE=${ui_mode} → ${export_dir}/" + ( + cd "$WEB_DIR" + export NEXT_PUBLIC_BASE_PATH="$base_path" + export NEXT_PUBLIC_UI_MODE="$ui_mode" + export NEXT_PUBLIC_CHATBOT_API_ROOT="${NEXT_PUBLIC_CHATBOT_API_ROOT:-/chatbot}" + export EXPORT_TARGET_DIR="$export_dir" + npm run build:deploy + ) +} + +build_variant "/chatbot/app" "app" "public" +build_variant "/chatbot/ai" "ai" "public-ai" +build_variant "/chatbot/banner" "banner" "public-banner" +build_variant "/chatbot/voicebot" "voicebot" "public-voicebot" + +if [[ -f "${EMBED_SRC}" ]]; then + cp "${EMBED_SRC}" "${SCRIPT_DIR}/public-banner/embed.js" + echo "[build] embed.js → public-banner/embed.js" +fi + +SAMPLE_SRC="${SCRIPT_DIR}/sample" +SAMPLE_DEST="${SCRIPT_DIR}/public-sample" +if [[ -d "${SAMPLE_SRC}" ]]; then + rm -rf "${SAMPLE_DEST}" + mkdir -p "${SAMPLE_DEST}" + cp -R "${SAMPLE_SRC}/." "${SAMPLE_DEST}/" + echo "[build] sample/ → public-sample/" +fi + +echo "[build] ✅ chatbotApp 배포 완료" +echo " - /chatbot/app/ → public/" +echo " - /chatbot/ai/ → public-ai/" +echo " - /chatbot/banner/ → public-banner/" +echo " - /chatbot/sample/ → public-sample/" +echo " - /chatbot/voicebot/ → public-voicebot/" diff --git a/chatbotApp-chatbot2.0-agent/docker-compose.yml b/chatbotApp-chatbot2.0-agent/docker-compose.yml new file mode 100644 index 0000000..744a1a6 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/docker-compose.yml @@ -0,0 +1,29 @@ +# chatbotApp 단독 실행 (카카오 인앱·브라우저 UI) +# cd chatbotApp && docker compose up -d +# → http://localhost:8089/chatbot/app/ +# +# 기본: /chatbot/* → https://chatbot.exdev.co.kr (원격 exAiChatBot) +# 로컬 RAG(:28012) 사용: +# CHATBOT_API_UPSTREAM=http://host.docker.internal:28012/chatbot/ \ +# CHATBOT_API_HOST=host.docker.internal docker compose up -d --force-recreate +services: + chatbot-app-web: + image: nginx:1.27-alpine + container_name: ex-chatbot-app-web + restart: unless-stopped + ports: + - "${CHATBOT_APP_PORT:-8089}:80" + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + CHATBOT_API_UPSTREAM: "${CHATBOT_API_UPSTREAM:-https://chatbot.exdev.co.kr/chatbot/}" + CHATBOT_API_HOST: "${CHATBOT_API_HOST:-chatbot.exdev.co.kr}" + volumes: + - ./public:/usr/share/nginx/html:ro + - ./public-ai:/usr/share/nginx/html-ai:ro + - ./public-banner:/usr/share/nginx/html-banner:ro + - ./public-sample:/usr/share/nginx/html-sample:ro + - ./public-voicebot:/usr/share/nginx/html-voicebot:ro + - ./nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro + - ./nginx/docker-entrypoint.sh:/docker-entrypoint.sh:ro + entrypoint: ["/bin/sh", "/docker-entrypoint.sh"] diff --git a/chatbotApp-chatbot2.0-agent/embed/chatbot-banner.js b/chatbotApp-chatbot2.0-agent/embed/chatbot-banner.js new file mode 100644 index 0000000..dda38fc --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/embed/chatbot-banner.js @@ -0,0 +1,85 @@ +/** + * ExChatbotBanner — 타 사이트 임베드용 검색 배너 SDK (iframe) + * @example + *
+ * + * + */ +(function (global) { + "use strict"; + + var DEFAULT_BASE = "https://chatbot.exdev.co.kr/chatbot/banner/"; + + function resolveContainer(el) { + if (!el) return null; + if (typeof el === "string") return document.querySelector(el); + return el; + } + + function init(options) { + options = options || {}; + var container = resolveContainer(options.container); + if (!container) { + console.error("[ExChatbotBanner] container not found"); + return null; + } + + var baseUrl = (options.baseUrl || DEFAULT_BASE).replace(/\/?$/, "/"); + var params = new URLSearchParams(); + if (options.embed !== false) params.set("embed", "1"); + if (options.placeholder) params.set("placeholder", String(options.placeholder)); + var qs = params.toString(); + var src = baseUrl + (qs ? "?" + qs : ""); + + var minHeight = Number(options.minHeight) || 56; + var maxHeight = Number(options.maxHeight) || 520; + var height = Number(options.height) || minHeight; + + var wrap = document.createElement("div"); + wrap.className = "ex-chatbot-banner-widget"; + wrap.style.cssText = + "position:relative;width:100%;max-width:" + + (options.maxWidth || "720px") + + ";margin:0 auto;"; + + var iframe = document.createElement("iframe"); + iframe.src = src; + iframe.title = options.title || "한국도로공사 AI 검색"; + iframe.setAttribute("allow", "clipboard-write"); + iframe.loading = options.loading || "lazy"; + iframe.style.cssText = + "display:block;width:100%;border:0;overflow:hidden;min-height:" + + minHeight + + "px;height:" + + height + + "px;transition:height 0.15s ease;"; + + function onMessage(ev) { + if (!ev.data || ev.data.type !== "ex-chatbot-banner-resize") return; + if (ev.source !== iframe.contentWindow) return; + var h = Number(ev.data.height); + if (h > 0) { + iframe.style.height = Math.min(Math.max(h, minHeight), maxHeight) + "px"; + } + } + + global.addEventListener("message", onMessage); + wrap.appendChild(iframe); + container.appendChild(wrap); + + return { + destroy: function () { + global.removeEventListener("message", onMessage); + wrap.remove(); + }, + iframe: iframe, + }; + } + + global.ExChatbotBanner = { + init: init, + version: "1.0.0", + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/chatbotApp-chatbot2.0-agent/nginx/default.conf b/chatbotApp-chatbot2.0-agent/nginx/default.conf new file mode 100644 index 0000000..32ef95f --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/nginx/default.conf @@ -0,0 +1,2 @@ +# 이 파일은 참고용입니다. docker compose는 default.conf.template + docker-entrypoint.sh 를 사용합니다. +# 기본 upstream: https://chatbot.exdev.co.kr/chatbot/ diff --git a/chatbotApp-chatbot2.0-agent/nginx/default.conf.template b/chatbotApp-chatbot2.0-agent/nginx/default.conf.template new file mode 100644 index 0000000..01280cd --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/nginx/default.conf.template @@ -0,0 +1,104 @@ +# chatbotApp 단독 웹서버 — Next.js 정적 UI + 챗 API 프록시 +# 기본 upstream: https://chatbot.exdev.co.kr (Caddy → kakaoChatbotSkill /web/ask) +# 로컬 kakao-skill(:8083) 사용 시: +# CHATBOT_API_UPSTREAM=http://host.docker.internal:8083/web/ +# CHATBOT_API_HOST=host.docker.internal +# 레거시 RAG 직접(:28012) 사용 시: +# CHATBOT_API_UPSTREAM=http://host.docker.internal:28012/chatbot/ +# +# Next.js 빌드(basePath=/chatbot/app)의 CSS·JS는 /chatbot/app/_next/ 경로를 사용합니다. +# /chatbot/ API 프록시보다 정적 location을 먼저 두어야 스타일이 적용됩니다. + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + client_max_body_size 1m; + + # Next.js 정적 자산 + SPA (운영과 동일 경로 /chatbot/app/, /chatbot/ai/) + location = /chatbot/app { + return 301 /chatbot/app/; + } + + location ^~ /chatbot/app/ { + alias /usr/share/nginx/html/; + index index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + location = /chatbot/ai { + return 301 /chatbot/ai/; + } + + location ^~ /chatbot/ai/ { + alias /usr/share/nginx/html-ai/; + index index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + location = /chatbot/banner { + return 301 /chatbot/banner/; + } + + location ^~ /chatbot/banner/ { + alias /usr/share/nginx/html-banner/; + index index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Content-Security-Policy "frame-ancestors *" always; + } + + location = /chatbot/sample { + return 301 /chatbot/sample/; + } + + location ^~ /chatbot/sample/ { + alias /usr/share/nginx/html-sample/; + index index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Permissions-Policy "microphone=*, camera=()" always; + } + + location = /chatbot/voicebot { + return 301 /chatbot/voicebot/; + } + + location ^~ /chatbot/voicebot/ { + alias /usr/share/nginx/html-voicebot/; + index index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Content-Security-Policy "frame-ancestors *" always; + add_header Permissions-Policy "microphone=*, camera=()" always; + } + + # 루트 접속 → basePath와 동일 URL로 이동 + location = / { + return 302 /chatbot/app/; + } + + # RAG API (ask/stream/faq/session/health 등) — 원격 exAiChatBot 또는 로컬 RAG + location /chatbot/ { + proxy_pass ${CHATBOT_API_UPSTREAM}; + proxy_http_version 1.1; + proxy_ssl_server_name on; + proxy_set_header Host ${CHATBOT_API_HOST}; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 10s; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + proxy_buffering off; + } + + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } +} diff --git a/chatbotApp-chatbot2.0-agent/nginx/docker-entrypoint.sh b/chatbotApp-chatbot2.0-agent/nginx/docker-entrypoint.sh new file mode 100755 index 0000000..db5b317 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/nginx/docker-entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -e + +export CHATBOT_API_UPSTREAM="${CHATBOT_API_UPSTREAM:-https://chatbot.exdev.co.kr/chatbot/}" +export CHATBOT_API_HOST="${CHATBOT_API_HOST:-chatbot.exdev.co.kr}" + +envsubst '${CHATBOT_API_UPSTREAM} ${CHATBOT_API_HOST}' \ + < /etc/nginx/templates/default.conf.template \ + > /etc/nginx/conf.d/default.conf + +exec nginx -g 'daemon off;' diff --git a/chatbotApp-chatbot2.0-agent/sample/css/minwon-sample.css b/chatbotApp-chatbot2.0-agent/sample/css/minwon-sample.css new file mode 100644 index 0000000..dad0ae8 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/sample/css/minwon-sample.css @@ -0,0 +1,97 @@ +.minwon-page .partcptn-header { + justify-content: space-between; +} + +.minwon-page .partcptn-logo { + text-decoration: none; +} + +.minwon-back-link { + font-size: 13px; + font-weight: 600; + color: #333; + text-decoration: none; +} + +.minwon-back-link:hover { + color: #005bac; +} + +.minwon-main { + flex: 1; + padding: 24px 20px 32px; + max-width: 480px; + margin: 0 auto; + width: 100%; +} + +.minwon-title { + margin: 0 0 10px; + font-size: 24px; + font-weight: 800; + letter-spacing: -0.03em; + color: #111; +} + +.minwon-lead { + margin: 0 0 24px; + font-size: 14px; + line-height: 1.65; + color: #444; +} + +.minwon-cards { + display: flex; + flex-direction: column; + gap: 12px; +} + +.minwon-card { + padding: 18px 16px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(0, 0, 0, 0.06); + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); +} + +.minwon-card h2 { + margin: 0 0 8px; + font-size: 16px; + font-weight: 700; + color: #111; +} + +.minwon-card p { + margin: 0 0 14px; + font-size: 13px; + line-height: 1.55; + color: #555; +} + +.minwon-card-btn { + border: 1px solid #246beb; + background: #fff; + color: #246beb; + border-radius: 999px; + padding: 8px 16px; + font-size: 13px; + font-weight: 700; + font-family: inherit; + cursor: pointer; +} + +.minwon-card-btn:hover { + background: #eef4ff; +} + +.minwon-note { + margin: 24px 0 0; + font-size: 12px; + line-height: 1.6; + color: #666; +} + +.minwon-note a { + color: #005bac; + font-weight: 600; +} diff --git a/chatbotApp-chatbot2.0-agent/sample/css/partcptn-sample.css b/chatbotApp-chatbot2.0-agent/sample/css/partcptn-sample.css new file mode 100644 index 0000000..511f074 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/sample/css/partcptn-sample.css @@ -0,0 +1,306 @@ +/* 국민참여로 (ex.co.kr/partcptn) 샘플 — 모바일 퍼스트 */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + font-family: "Pretendard", "Malgun Gothic", "Apple SD Gothic Neo", sans-serif; + color: #111; + background: #dfe8f0; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +button { + font-family: inherit; +} + +.partcptn-page { + min-height: 100vh; + min-height: 100dvh; + display: flex; + flex-direction: column; + max-width: 480px; + margin: 0 auto; + background: #e8eef4; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04); +} + +.partcptn-header { + position: relative; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + background: rgba(255, 255, 255, 0.92); +} + +.partcptn-logo { + display: flex; + align-items: center; + gap: 6px; + font-size: 0; +} + +.partcptn-logo-ex { + font-size: 22px; + font-weight: 900; + letter-spacing: -0.04em; + background: linear-gradient(90deg, #e60012 0%, #005bac 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.partcptn-logo-text { + font-size: 17px; + font-weight: 800; + color: #111; + letter-spacing: -0.03em; +} + +.partcptn-menu-btn { + display: flex; + align-items: center; + gap: 6px; + border: none; + background: transparent; + font-size: 14px; + font-weight: 600; + color: #333; + cursor: pointer; + padding: 4px 0; +} + +.partcptn-menu-icon { + display: flex; + flex-direction: column; + gap: 4px; + width: 18px; +} + +.partcptn-menu-icon span { + display: block; + height: 2px; + background: #333; + border-radius: 1px; +} + +.partcptn-hero { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: visible; +} + +.partcptn-hero-bg { + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgba(232, 238, 244, 0.2) 0%, rgba(210, 220, 232, 0.45) 100%), + radial-gradient(ellipse 70% 50% at 50% 55%, rgba(255, 220, 150, 0.35) 0%, transparent 55%), + radial-gradient(ellipse 90% 60% at 50% 40%, rgba(200, 215, 235, 0.9) 0%, rgba(180, 195, 215, 0.6) 100%); + pointer-events: none; +} + +.partcptn-hero-bg::after { + content: ""; + position: absolute; + left: 50%; + top: 42%; + transform: translate(-50%, -50%); + width: 120px; + height: 160px; + background: radial-gradient(ellipse, rgba(255, 240, 200, 0.5) 0%, transparent 70%); + filter: blur(8px); +} + +.partcptn-hero-content { + position: relative; + z-index: 2; + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 28px 20px 24px; + overflow: visible; +} + +.partcptn-hero-title { + margin: 0 0 16px; + font-size: clamp(22px, 5.5vw, 28px); + font-weight: 800; + line-height: 1.35; + letter-spacing: -0.03em; + color: #111; +} + +.partcptn-hero-sub { + margin: 0 0 28px; + font-size: 14px; + line-height: 1.65; + color: #333; + max-width: 320px; +} + +.partcptn-search-slot { + width: 100%; + max-width: 340px; + background: transparent; + line-height: 0; + overflow: visible; + position: relative; + z-index: 30; +} + +.partcptn-search-slot iframe { + display: block; + width: 100%; + border: 0; + min-height: 48px; + background: transparent !important; + overflow: visible; + vertical-align: top; +} + +.partcptn-search-slot.is-expanded { + margin-bottom: 4px; +} + +.partcptn-page--search-open .partcptn-hero { + flex: 1 1 auto; + min-height: min(72vh, 640px); +} + +.partcptn-bottom-dock { + position: relative; + z-index: 5; + flex-shrink: 0; + display: flex; + align-items: stretch; + justify-content: space-around; + gap: 0; + margin-top: auto; + padding: 18px 12px calc(18px + env(safe-area-inset-bottom, 0px)); + background: rgba(58, 54, 50, 0.62); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.partcptn-dock-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 10px; + min-width: 0; + padding: 4px 8px 2px; + border: none; + background: transparent; + color: #fff; + cursor: pointer; + text-align: center; + transition: opacity 0.15s; +} + +.partcptn-dock-item:hover { + opacity: 0.88; +} + +.partcptn-dock-item:active { + opacity: 0.75; +} + +.partcptn-dock-icon { + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + flex-shrink: 0; +} + +.partcptn-dock-icon svg { + width: 44px; + height: 44px; + display: block; +} + +.partcptn-dock-label { + display: block; + font-size: 12px; + font-weight: 500; + line-height: 1.35; + letter-spacing: -0.02em; + color: rgba(255, 255, 255, 0.96); + word-break: keep-all; +} + +.sample-chat-overlay { + position: fixed; + inset: 0; + z-index: 9999; + display: none; + align-items: center; + justify-content: center; + padding: 16px; + background: rgba(15, 23, 42, 0.45); +} + +.sample-chat-overlay.is-open { + display: flex; +} + +.sample-chat-panel { + position: relative; + width: min(100%, 420px); + height: min(88vh, 720px); + border-radius: 16px; + overflow: hidden; + background: #fff; + box-shadow: 0 24px 48px rgba(15, 23, 42, 0.2); +} + +.sample-chat-panel iframe { + display: block; + width: 100%; + height: 100%; + border: 0; +} + +.sample-chat-close { + position: absolute; + top: 10px; + right: 10px; + z-index: 2; + width: 36px; + height: 36px; + border: none; + border-radius: 50%; + background: rgba(255, 255, 255, 0.95); + color: #333; + font-size: 22px; + line-height: 1; + cursor: pointer; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); +} + +@media (min-width: 481px) { + .partcptn-page { + min-height: 100vh; + border-left: 1px solid rgba(0, 0, 0, 0.06); + border-right: 1px solid rgba(0, 0, 0, 0.06); + } +} diff --git a/chatbotApp-chatbot2.0-agent/sample/index.html b/chatbotApp-chatbot2.0-agent/sample/index.html new file mode 100644 index 0000000..4113e35 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/sample/index.html @@ -0,0 +1,244 @@ + + + + + + 국민참여로 - 국민과 함께, 열린 도로, 열린 소통 + + + + +
+
+ + +
+ +
+ + +
+

국민과 함께, 열린 도로, 열린 소통

+

+ 국민의 작은 목소리도 정책의 시작이 됩니다.
+ 소통하고, 함께 만들어가는 스마트 국민 소통 플랫폼 +

+ +
+ +
+
+
+ + +
+ + + + + + diff --git a/chatbotApp-chatbot2.0-agent/sample/minwon/index.html b/chatbotApp-chatbot2.0-agent/sample/minwon/index.html new file mode 100644 index 0000000..2d64011 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/sample/minwon/index.html @@ -0,0 +1,52 @@ + + + + + + 민원신청 - 국민참여로 + + + + +
+
+ + ← 샘플 홈 +
+ +
+

민원신청

+

+ 고속도로 이용과 관련된 민원을 온라인으로 접수·조회하실 수 있습니다. +

+ +
+
+

포장 파손 피해 배상

+

고속도로 포장 파손으로 인한 차량 피해 배상을 신청합니다.

+ +
+
+

도로 점용 허가

+

고속도로 구간 내 점용·굴착 등 허가를 신청합니다.

+ +
+
+

일반 민원 접수

+

기타 고속도로 관련 민원을 접수합니다.

+ +
+
+ +

+ 본 페이지는 국민참여로 샘플 연동용 데모 화면입니다. 실제 민원 접수는 + 한국도로공사 국민참여로 + 를 이용해 주세요. +

+
+
+ + diff --git a/chatbotApp-chatbot2.0-agent/start.sh b/chatbotApp-chatbot2.0-agent/start.sh new file mode 100755 index 0000000..162a63c --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/start.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# chatbotApp 단독 웹서버 기동 (nginx + RAG API 프록시) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +export PATH="/usr/local/bin:/Applications/Docker.app/Contents/Resources/bin:${PATH}" + +PORT="${CHATBOT_APP_PORT:-8089}" +RAG_PORT="${RAG_API_PORT:-28012}" +export CHATBOT_API_UPSTREAM="${CHATBOT_API_UPSTREAM:-https://chatbot.exdev.co.kr/chatbot/}" +export CHATBOT_API_HOST="${CHATBOT_API_HOST:-chatbot.exdev.co.kr}" + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } + +if ! docker info >/dev/null 2>&1; then + log "ERROR: Docker Engine 미기동" + exit 1 +fi + +# :8089 점유 시 Caddy/GoldenChart 등과 충돌 — chatbotApp 전용 포트 확보 +if docker ps --format '{{.Names}} {{.Ports}}' 2>/dev/null | grep -E '8089->8089|:8089->8089' | grep -qv 'ex-chatbot-app-web'; then + log "⚠️ :8089 다른 컨테이너(Caddy 등) 점유 — docker stop ex-chatbot-caddy 후 재시도" +fi + +docker compose up -d --force-recreate + +sleep 2 +APP_URL="http://127.0.0.1:${PORT}/chatbot/app/" +BODY="$(curl -sf --max-time 5 "$APP_URL" 2>/dev/null || true)" + +if echo "$BODY" | grep -q 'data-chatbot-app="next-sse"'; then + log "✅ chatbotApp UI: ${APP_URL}" +elif echo "$BODY" | grep -qi 'GoldenChart'; then + log "❌ GoldenChart 화면 — :${PORT} 포트 충돌" + log " 1) docker stop ex-chatbot-caddy (CADDY_HTTP_PORT는 9089 권장)" + log " 2) cd chatbotApp && docker compose up -d --force-recreate" + log " 3) ${APP_URL} 재접속" + exit 1 +else + log "⚠️ UI 미응답 — docker logs ex-chatbot-app-web" +fi + +if curl -sf --max-time 10 "http://127.0.0.1:${PORT}/chatbot/health" >/dev/null 2>&1; then + log "✅ API 프록시 연결 가능 → ${CHATBOT_API_UPSTREAM}" +else + log "⚠️ API 프록시 미응답 — 원격 ${CHATBOT_API_UPSTREAM} 또는 네트워크 확인" +fi + +if curl -sf --max-time 5 "http://127.0.0.1:${RAG_PORT}/chatbot/health" >/dev/null 2>&1; then + log "ℹ️ 로컬 RAG :${RAG_PORT} 기동 중 (프록시 대상 변경 시 CHATBOT_API_UPSTREAM 사용)" +fi + +log "원격 API: ${CHATBOT_API_UPSTREAM}" +log "운영 URL (Caddy): https://chatbot.exdev.co.kr/chatbot/app/" diff --git a/chatbotApp-chatbot2.0-agent/web/.gitignore b/chatbotApp-chatbot2.0-agent/web/.gitignore new file mode 100644 index 0000000..7833851 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.next/ +out/ +*.tsbuildinfo diff --git a/chatbotApp-chatbot2.0-agent/web/next-env.d.ts b/chatbotApp-chatbot2.0-agent/web/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/chatbotApp-chatbot2.0-agent/web/next.config.mjs b/chatbotApp-chatbot2.0-agent/web/next.config.mjs new file mode 100644 index 0000000..3ee623c --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/next.config.mjs @@ -0,0 +1,29 @@ +/** @type {import('next').NextConfig} */ +const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ""; + +const remoteApiOrigin = ( + process.env.CHATBOT_API_UPSTREAM || + process.env.NEXT_PUBLIC_CHATBOT_API_BASE || + "https://chatbot.exdev.co.kr" +).replace(/\/chatbot\/?$/, ""); + +const nextConfig = { + output: "export", + basePath, + assetPrefix: basePath || undefined, + trailingSlash: true, + reactStrictMode: true, + poweredByHeader: false, + images: { unoptimized: true }, + async rewrites() { + if (process.env.NODE_ENV !== "development") return []; + return [ + { + source: "/chatbot/:path*", + destination: `${remoteApiOrigin}/chatbot/:path*`, + }, + ]; + }, +}; + +export default nextConfig; diff --git a/chatbotApp-chatbot2.0-agent/web/package-lock.json b/chatbotApp-chatbot2.0-agent/web/package-lock.json new file mode 100644 index 0000000..fcd5c3c --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/package-lock.json @@ -0,0 +1,1993 @@ +{ + "name": "chatbot-app-web", + "version": "3.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "chatbot-app-web", + "version": "3.0.0", + "dependencies": { + "next": "^14.2.29", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-markdown": "^9.0.3", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "typescript": "^5.4.5" + } + }, + "node_modules/@next/env": { + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz", + "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz", + "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz", + "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz", + "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz", + "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz", + "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz", + "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz", + "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", + "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz", + "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", + "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "license": "ISC" + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", + "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", + "license": "MIT", + "dependencies": { + "@next/env": "14.2.35", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.33", + "@next/swc-darwin-x64": "14.2.33", + "@next/swc-linux-arm64-gnu": "14.2.33", + "@next/swc-linux-arm64-musl": "14.2.33", + "@next/swc-linux-x64-gnu": "14.2.33", + "@next/swc-linux-x64-musl": "14.2.33", + "@next/swc-win32-arm64-msvc": "14.2.33", + "@next/swc-win32-ia32-msvc": "14.2.33", + "@next/swc-win32-x64-msvc": "14.2.33" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-markdown": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", + "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/chatbotApp-chatbot2.0-agent/web/package.json b/chatbotApp-chatbot2.0-agent/web/package.json new file mode 100644 index 0000000..96b989d --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/package.json @@ -0,0 +1,25 @@ +{ + "name": "chatbot-app-web", + "private": true, + "version": "3.0.0", + "scripts": { + "dev": "next dev -p 3000", + "build": "next build", + "build:deploy": "next build && node scripts/export-public.mjs", + "lint": "next lint", + "start": "next start" + }, + "dependencies": { + "next": "^14.2.29", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-markdown": "^9.0.3", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "typescript": "^5.4.5" + } +} diff --git a/chatbotApp-chatbot2.0-agent/web/public/images/giltong.jpg b/chatbotApp-chatbot2.0-agent/web/public/images/giltong.jpg new file mode 100644 index 0000000..9dd0509 Binary files /dev/null and b/chatbotApp-chatbot2.0-agent/web/public/images/giltong.jpg differ diff --git a/chatbotApp-chatbot2.0-agent/web/scripts/export-public.mjs b/chatbotApp-chatbot2.0-agent/web/scripts/export-public.mjs new file mode 100644 index 0000000..e3e27a0 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/scripts/export-public.mjs @@ -0,0 +1,48 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const webRoot = path.resolve(scriptDir, ".."); +const outRoot = path.join(webRoot, "out"); +const sourceDir = outRoot; +const targetName = process.env.EXPORT_TARGET_DIR || "public"; +const targetDir = path.join(webRoot, "..", targetName); +const basePath = process.env.NEXT_PUBLIC_BASE_PATH || "/chatbot/app"; +const uiMode = process.env.NEXT_PUBLIC_UI_MODE || "app"; + +if (!fs.existsSync(sourceDir)) { + console.error(`[export-public] 빌드 출력 없음: ${sourceDir}`); + console.error("NEXT_PUBLIC_BASE_PATH=/chatbot/app npm run build:deploy"); + process.exit(1); +} + +function copyRecursive(src, dest) { + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const from = path.join(src, entry.name); + const to = path.join(dest, entry.name); + if (entry.isDirectory()) { + copyRecursive(from, to); + } else { + fs.copyFileSync(from, to); + } + } +} + +for (const name of fs.existsSync(targetDir) ? fs.readdirSync(targetDir) : []) { + const p = path.join(targetDir, name); + if (name === ".gitkeep") continue; + fs.rmSync(p, { recursive: true, force: true }); +} + +copyRecursive(sourceDir, targetDir); + +const versionPath = path.join(targetDir, "VERSION"); +const version = fs.existsSync(versionPath) + ? fs.readFileSync(versionPath, "utf8").trim() + : "3.0.0"; +const stamp = `${version} (${uiMode}@${basePath})`; +fs.writeFileSync(versionPath, `${stamp}\n`, "utf8"); + +console.log(`[export-public] ✅ ${sourceDir} → ${targetDir} (${stamp})`); diff --git a/chatbotApp-chatbot2.0-agent/web/src/app/globals.css b/chatbotApp-chatbot2.0-agent/web/src/app/globals.css new file mode 100644 index 0000000..a9159f4 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/src/app/globals.css @@ -0,0 +1,1095 @@ +:root { + --kakao-bg: #b2c7d9; + --kakao-yellow: #fee500; + --bot-bubble: #ffffff; + --text: #111111; + --subtext: #555555; + --time: #6b7b8c; + --border: rgba(0, 0, 0, 0.06); + --safe-top: env(safe-area-inset-top, 0px); + --safe-bottom: env(safe-area-inset-bottom, 0px); + --composer-h: 58px; + --bubble-max: min(78vw, 520px); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + width: 100%; + height: 100%; + height: -webkit-fill-available; + overflow: hidden; + background: var(--kakao-bg); + font-family: -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif; + color: var(--text); + -webkit-text-size-adjust: 100%; +} + +.app { + display: flex; + flex-direction: column; + width: 100%; + max-width: none; + height: 100%; + height: 100vh; + height: 100dvh; + height: -webkit-fill-available; + min-height: 100vh; + min-height: 100dvh; + min-height: -webkit-fill-available; + margin: 0; + background: var(--kakao-bg); +} + +.app-header { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 12px; + padding: calc(10px + var(--safe-top)) clamp(12px, 3vw, 32px) 8px; + background: var(--kakao-bg); +} + +.app-header-brand { + flex: 1; + min-width: 0; + overflow: hidden; +} + +.app-header-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + margin-left: auto; +} + +.app-btn-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border: none; + border-radius: 50%; + background: rgba(255, 255, 255, 0.92); + color: #333; + cursor: pointer; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); +} + +.app-btn-close:active { + transform: scale(0.96); +} + +.chat { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + padding: calc(12px + var(--safe-top)) clamp(12px, 3vw, 32px) 12px; + scroll-behavior: smooth; +} + +.msg-row { + display: flex; + margin-bottom: clamp(10px, 2vh, 16px); +} + +.msg-row.user { + justify-content: flex-end; +} + +.msg-row.bot { + justify-content: flex-start; + align-items: flex-end; + gap: clamp(6px, 1.5vw, 10px); +} + +.bot-avatar { + width: clamp(34px, 8vw, 42px); + height: clamp(34px, 8vw, 42px); + border-radius: 14px; + background: #fff; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); +} + +.bot-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + border-radius: 14px; +} + +.bot-content { + max-width: min(calc(100% - 52px), calc(100% - clamp(34px, 8vw, 42px) - 16px)); +} + +.bot-name { + margin: 0 0 4px 2px; +} + +.bubble { + position: relative; + padding: clamp(8px, 2vw, 12px) clamp(10px, 2.5vw, 14px); + border-radius: 16px; + font-size: clamp(14px, 3.5vw, 16px); + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); +} + +.bubble.user { + background: var(--kakao-yellow); + border-top-right-radius: 4px; + max-width: var(--bubble-max); +} + +.bubble.bot { + background: var(--bot-bubble); + border-top-left-radius: 4px; + max-width: min(85vw, 560px); +} + +.bubble.bot.bubble--rich { + white-space: normal; + padding: clamp(10px, 2.5vw, 14px) clamp(12px, 3vw, 16px); + line-height: 1.65; +} + +/* ── ChatGPT / Gemini 스타일 마크다운 ── */ +.bubble-body.md-content { + display: flex; + flex-direction: column; + gap: 0.15em; + font-size: clamp(14px, 3.5vw, 16px); + color: #0d0d0d; +} + +.md-content > * + * { + margin-top: 0.1em; +} + +.md-p { + margin: 0 0 0.75em; + line-height: 1.72; + letter-spacing: -0.01em; +} + +.md-p:last-child { + margin-bottom: 0; +} + +.md-p--compact { + margin-bottom: 0.4em; +} + +.md-p--compact:last-child { + margin-bottom: 0; +} + +.md-h { + margin: 1em 0 0.45em; + font-weight: 700; + line-height: 1.35; + letter-spacing: -0.02em; + color: #0d0d0d; +} + +.md-h:first-child { + margin-top: 0; +} + +.md-h1 { font-size: 1.12em; } +.md-h2 { font-size: 1.06em; } +.md-h3, .md-h4 { font-size: 1em; } + +.md-strong { + font-weight: 700; + color: #0d0d0d; +} + +.md-em { + font-style: italic; +} + +.md-inline-code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.88em; + padding: 0.12em 0.38em; + border-radius: 5px; + background: #f4f4f4; + color: #c7254e; + word-break: break-word; +} + +.md-link { + color: #007aff; + text-decoration: none; + word-break: break-all; +} + +.md-link:active { + opacity: 0.75; +} + +.md-list { + margin: 0.25em 0 0.75em; + padding-left: 1.4em; +} + +.md-list--bullet { + list-style: disc; +} + +.md-list--ordered { + list-style: decimal; +} + +.md-list--nested { + margin: 0.35em 0 0.15em; + padding-left: 1.15em; +} + +.md-li { + margin: 0.4em 0; + padding-left: 0.15em; + line-height: 1.65; +} + +.md-li::marker { + color: #444; + font-weight: 600; +} + +.md-blockquote { + margin: 0.65em 0; + padding: 0.55em 0 0.55em 0.85em; + border-left: 3px solid #d0d0d0; + color: #444; + background: linear-gradient(90deg, rgba(0, 0, 0, 0.03) 0%, transparent 100%); + border-radius: 0 6px 6px 0; +} + +.md-hr { + border: none; + border-top: 1px solid #e5e5e5; + margin: 0.85em 0; +} + +.md-code-wrap { + margin: 0.65em 0 0.85em; + border-radius: 10px; + overflow: hidden; + background: #f7f7f8; + border: 1px solid #ececec; +} + +.md-code-lang { + padding: 6px 12px; + font-size: 0.72em; + font-weight: 600; + color: #666; + background: #efefef; + border-bottom: 1px solid #e5e5e5; + text-transform: lowercase; +} + +.md-pre { + margin: 0; + padding: 12px 14px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82em; + line-height: 1.55; + color: #1a1a1a; + white-space: pre-wrap; + word-break: break-word; +} + +.md-pre code { + font-family: inherit; + background: none; + padding: 0; + color: inherit; +} + +.bubble-body { + display: flex; + flex-direction: column; +} + +.msg-time { + margin-top: 4px; + font-size: clamp(10px, 2.2vw, 12px); + color: var(--time); +} + +.msg-row.user .msg-time { + text-align: right; +} + +.msg-row.bot .msg-time { + margin-left: 2px; +} + +.bubble.loading { + color: var(--subtext); +} + +.typing { + display: inline-flex; + gap: 4px; + align-items: center; + margin-left: 4px; +} + +.typing span { + width: 6px; + height: 6px; + border-radius: 50%; + background: #8a96a3; + animation: bounce 1.2s infinite ease-in-out; +} + +.typing span:nth-child(2) { animation-delay: 0.15s; } +.typing span:nth-child(3) { animation-delay: 0.3s; } + +@keyframes bounce { + 0%, 80%, 100% { transform: translateY(0); opacity: 0.45; } + 40% { transform: translateY(-4px); opacity: 1; } +} + +/* ── 입력창 위 관련 질문 (app 모드) ── */ +.related-questions-bar--app { + flex-shrink: 0; + border-top: 1px solid var(--border); + background: #eef2f6; + padding: 10px clamp(10px, 3vw, 24px) 8px; +} + +.related-questions-bar__head { + margin: 0 0 8px; + font-size: 12px; + font-weight: 700; + color: var(--subtext); + letter-spacing: -0.01em; +} + +.related-questions-bar__scroll { + display: flex; + gap: 8px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + padding-bottom: 2px; +} + +.related-questions-bar__scroll::-webkit-scrollbar { + display: none; +} + +.related-questions-bar__chip { + flex-shrink: 0; + border: 1px solid #d8dce3; + background: #fff; + color: var(--text); + border-radius: 999px; + padding: 8px 14px; + font-size: 13px; + line-height: 1.35; + cursor: pointer; + white-space: nowrap; + max-width: min(85vw, 320px); + overflow: hidden; + text-overflow: ellipsis; +} + +.related-questions-bar__chip:hover:not(:disabled) { + background: #f5f7fa; + border-color: #c5cad3; +} + +.related-questions-bar__chip:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.composer { + position: relative; + z-index: 10; + flex-shrink: 0; + overflow: visible; + padding: clamp(8px, 2vw, 12px) clamp(10px, 3vw, 24px) calc(clamp(8px, 2vw, 12px) + var(--safe-bottom)); + background: #f6f6f6; + border-top: 1px solid var(--border); +} + +.composer form { + display: flex; + gap: clamp(6px, 1.5vw, 10px); + align-items: center; + width: 100%; + max-width: none; +} + +.composer input { + flex: 1; + min-width: 0; + height: clamp(40px, 10vw, 44px); + border: 1.5px solid #007aff; + border-radius: 999px; + padding: 0 clamp(14px, 3.5vw, 18px); + font-size: clamp(14px, 3.5vw, 16px); + background: #fff; + outline: none; +} + +.composer input:focus { + border-color: #007aff; +} + +.composer input:disabled { + background: #fafafa; + border-color: #c8c8c8; +} + +.btn-send { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + height: clamp(36px, 9vw, 40px); + min-width: clamp(56px, 15vw, 68px); + padding: 0 clamp(16px, 4vw, 20px); + border: none; + border-radius: 999px; + font-size: clamp(14px, 3.5vw, 16px); + font-weight: 500; + font-family: inherit; + line-height: 1; + cursor: default; + transition: background 0.15s ease, color 0.15s ease; + background: #ececec; + color: #a8a8a8; +} + +.btn-send.is-ready { + background: #007aff; + color: #fff; + cursor: pointer; +} + +.btn-send.is-ready:active { + transform: scale(0.97); +} + +.faq-wrap { + position: relative; + flex-shrink: 0; +} + +.btn-faq { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: clamp(40px, 10vw, 44px); + height: clamp(40px, 10vw, 44px); + border: none; + border-radius: 50%; + cursor: pointer; + background: #fff; + color: #007aff; + border: 1.5px solid #007aff; + transition: transform 0.15s ease, background 0.15s ease, color 0.15s ease; +} + +.btn-faq:active, +.btn-faq.is-open { + transform: scale(0.96); + background: #007aff; + color: #fff; +} + +.btn-faq:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.faq-panel[hidden], +.faq-panel.faq-panel--closed { + display: none !important; +} + +.faq-panel { + position: fixed; + left: 50%; + right: auto; + transform: translateX(-50%); + bottom: calc(72px + var(--safe-bottom)); + width: min(92vw, 360px); + max-height: min(62vh, 520px); + background: #fff; + border-radius: 14px; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18); + border: 1px solid rgba(0, 0, 0, 0.08); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 1000; +} + +.faq-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid #eee; + font-size: 0.92rem; + font-weight: 700; + color: #222; + flex-shrink: 0; +} + +.faq-close { + border: none; + background: transparent; + font-size: 1.4rem; + line-height: 1; + color: #888; + cursor: pointer; + padding: 0 4px; +} + +.faq-body { + overflow-y: auto; + -webkit-overflow-scrolling: touch; + padding: 8px 10px 12px; +} + +.faq-loading, +.faq-empty { + margin: 12px 8px; + font-size: 0.85rem; + color: #666; +} + +.faq-section + .faq-section { + margin-top: 10px; +} + +.faq-cat { + margin: 8px 6px 6px; + font-size: 0.78rem; + font-weight: 700; + color: #007aff; + letter-spacing: -0.01em; +} + +.faq-list { + list-style: none; + margin: 0; + padding: 0; +} + +.faq-item { + display: block; + width: 100%; + text-align: left; + border: none; + background: #f7f9fc; + border-radius: 10px; + padding: 10px 12px; + margin-bottom: 6px; + font-size: 0.84rem; + line-height: 1.45; + color: #222; + cursor: pointer; + font-family: inherit; +} + +.faq-item:active { + background: #e8f2ff; +} + +.btn-voice { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: clamp(40px, 10vw, 44px); + height: clamp(40px, 10vw, 44px); + border: none; + border-radius: 50%; + cursor: pointer; + transition: transform 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; + background: #fff; + color: #444; + border: 1px solid #ddd; +} + +.btn-voice:active { + transform: scale(0.96); +} + +.btn-send:disabled, +.btn-voice:disabled, +.composer input:disabled { + opacity: 1; +} + +.btn-voice:disabled, +.composer input:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.btn-voice.listening { + background: #ff4d4f; + border-color: #ff4d4f; + color: #fff; + box-shadow: 0 0 0 4px rgba(255, 77, 79, 0.25); + animation: pulse-mic 1.2s ease-in-out infinite; +} + +.btn-voice.unsupported { + opacity: 0.35; + cursor: not-allowed; +} + +@keyframes pulse-mic { + 0%, 100% { box-shadow: 0 0 0 4px rgba(255, 77, 79, 0.2); } + 50% { box-shadow: 0 0 0 8px rgba(255, 77, 79, 0.12); } +} + +.welcome-card { + margin: 8px 0 clamp(14px, 3vh, 20px); + padding: clamp(12px, 3vw, 16px); + background: rgba(255, 255, 255, 0.72); + border-radius: 14px; + font-size: clamp(13px, 3.2vw, 15px); + line-height: 1.6; + color: #333; + max-width: 100%; +} + +/* ── Kakao ListCard 스타일 (주유·휴게소 등) ── */ +.list-card-stack { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 8px; + max-width: min(85vw, 360px); +} + +.action-buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + max-width: min(85vw, 360px); +} + +.action-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + padding: 8px 12px; + border: 1px solid rgba(0, 122, 255, 0.28); + border-radius: 999px; + background: #fff; + color: #007aff; + font-size: 13px; + font-weight: 700; + line-height: 1.2; + text-decoration: none; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.action-button:active { + transform: scale(0.98); +} + +.list-card { + background: #fff; + border-radius: 14px; + border: 1px solid rgba(0, 0, 0, 0.06); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + overflow: hidden; +} + +.list-card__title { + margin: 0; + padding: 12px 14px 8px; + font-size: 15px; + font-weight: 700; + line-height: 1.4; + color: #111; +} + +.list-card__items { + list-style: none; + margin: 0; + padding: 0 10px 10px; +} + +.list-card__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 4px; + border-top: 1px solid rgba(0, 0, 0, 0.05); +} + +.list-card__row-link { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; + color: inherit; + text-decoration: none; +} + +.list-card__row-link:active { + opacity: 0.72; +} + +.list-card__row:first-child { + border-top: none; +} + +.list-card__meta { + min-width: 0; + flex: 1; +} + +.list-card__price { + font-size: 17px; + font-weight: 700; + color: #111; + line-height: 1.3; +} + +.list-card__location { + margin-top: 2px; + font-size: 12px; + color: #777; + line-height: 1.3; +} + +.list-card__fuel { + flex-shrink: 0; + width: 52px; + height: 52px; + border-radius: 10px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + background: #f5f5f5; + color: #333; +} + +.list-card__fuel-icon { + font-size: 16px; + line-height: 1; +} + +.list-card__thumb { + width: 36px; + height: 36px; + object-fit: contain; + display: block; +} + +.list-card__fuel-label { + font-size: 10px; + font-weight: 700; + line-height: 1.1; +} + +.list-card__fuel--gasoline { + background: #1a1a1a; + color: #fff; +} + +.list-card__fuel--diesel { + background: #f58220; + color: #fff; +} + +.list-card__fuel--lpg { + background: #f5c518; + color: #222; +} + +.list-card__fuel--electric { + background: #007aff; + color: #fff; +} + +.list-card__fuel--hydrogen { + background: #34c759; + color: #fff; +} + +.list-card__fuel--food { + background: #ff9500; + color: #fff; +} + +.list-card__fuel--food-soup, +.list-card__fuel--food-rice, +.list-card__fuel--food-meat, +.list-card__fuel--food-fish, +.list-card__fuel--food-noodle, +.list-card__fuel--food-coffee, +.list-card__fuel--food-bread, +.list-card__fuel--food-drink, +.list-card__fuel--food-premium, +.list-card__fuel--food-recommend { + background: #ff9500; + color: #fff; +} + +.list-card__fuel--facility, +.list-card__fuel--facility-nursery, +.list-card__fuel--facility-shower, +.list-card__fuel--facility-pharmacy, +.list-card__fuel--facility-carwash, +.list-card__fuel--facility-repair { + background: #5856d6; + color: #fff; +} + +.list-card__fuel--shop, +.list-card__fuel--theme, +.list-card__fuel--recruit { + background: #007aff; + color: #fff; +} + +.list-card__fuel--road-smooth { + background: #34c759; + color: #fff; +} + +.list-card__fuel--road-slow { + background: #ffcc00; + color: #222; +} + +.list-card__fuel--road-congestion, +.list-card__fuel--road-accident { + background: #ff3b30; + color: #fff; +} + +.list-card__fuel--road-work { + background: #ff9500; + color: #fff; +} + +.list-card__fuel--unknown { + background: #8e8e93; + color: #fff; +} + +/* ── 유사 질문 Quick Reply (카카오톡 스타일) ── */ +.quick-reply-wrap { + margin-top: 10px; + max-width: min(85vw, 360px); +} + +.quick-reply-title { + margin: 0 0 8px; + font-size: 12px; + font-weight: 600; + color: #666; +} + +.quick-reply-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.quick-reply-chip { + display: inline-flex; + align-items: center; + max-width: 100%; + padding: 8px 12px; + border: 1px solid #d8dce3; + border-radius: 18px; + background: #fff; + color: #222; + font-size: 13px; + line-height: 1.35; + text-align: left; + cursor: pointer; + font-family: inherit; + transition: background 0.15s ease, border-color 0.15s ease; +} + +.quick-reply-chip:hover:not(:disabled) { + background: #fff9db; + border-color: #fee500; +} + +.quick-reply-chip:active:not(:disabled) { + background: #fee500; +} + +.quick-reply-chip:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +@media (min-width: 1024px) { + :root { + --bubble-max: min(520px, 45vw); + } + + .chat { + padding-left: max(32px, 8vw); + padding-right: max(32px, 8vw); + } + + .composer form { + max-width: 1200px; + margin: 0 auto; + } +} + +.bot-avatar-fallback { + font-size: 0.72rem; + font-weight: 700; + color: #007aff; +} + +.loading-text { + color: var(--subtext); +} + +.md-content p { + margin: 0 0 0.75em; + line-height: 1.72; +} + +.md-content p:last-child { + margin-bottom: 0; +} + +.md-content ul, +.md-content ol { + margin: 0.25em 0 0.75em; + padding-left: 1.4em; +} + +.md-content li { + margin: 0.4em 0; + line-height: 1.65; +} + +.suggestion-block { + margin-top: 0.85em; +} + +.suggestion-block__title { + margin: 0 0 0.65em; + font-weight: 500; +} + +.suggestion-block__list { + list-style: none; + margin: 0.25em 0 0.75em; + padding: 0; +} + +.suggestion-block__list li { + margin: 0.28em 0; +} + +.suggestion-block__button { + display: inline-flex; + align-items: flex-start; + gap: 0.35em; + max-width: 100%; + border: 0; + padding: 0.1em 0; + background: transparent; + color: inherit; + font: inherit; + line-height: 1.65; + text-align: left; + cursor: pointer; +} + +.suggestion-block__button:hover { + color: #006ee6; + text-decoration: underline; + text-underline-offset: 3px; +} + +.suggestion-block__button:disabled { + cursor: default; + text-decoration: none; +} + +.suggestion-block__index { + flex-shrink: 0; +} + +.md-content h1, +.md-content h2, +.md-content h3, +.md-content h4 { + margin: 1em 0 0.45em; + font-weight: 700; + line-height: 1.35; +} + +.md-content h1:first-child, +.md-content h2:first-child, +.md-content h3:first-child { + margin-top: 0; +} + +.md-content a { + color: #007aff; + word-break: break-all; +} + +.md-content code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.88em; + padding: 0.12em 0.38em; + border-radius: 5px; + background: #f4f4f4; +} + +.faq-category-title { + margin: 8px 6px 6px; + font-size: 0.78rem; + font-weight: 700; + color: #007aff; +} diff --git a/chatbotApp-chatbot2.0-agent/web/src/app/layout.tsx b/chatbotApp-chatbot2.0-agent/web/src/app/layout.tsx new file mode 100644 index 0000000..76a75e9 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/src/app/layout.tsx @@ -0,0 +1,56 @@ +import type { Metadata, Viewport } from "next"; +import { getUiMode } from "@/lib/uiMode"; +import "./globals.css"; + +const uiMode = getUiMode(); + +const titles: Record = { + app: "ex AI Chatbot 2.0", + ai: "ex AI Chatbot 2.0", + banner: "ex AI Chatbot 2.0", + voicebot: "ex AI Chatbot 2.0", +}; + +const descriptions: Record = { + app: "고속도로 이용 관련 AI 상담", + ai: "고속도로·휴게소·통행료 AI 상담 (AI 모드)", + banner: "검색창 + 드롭다운 AI 검색 (임베드용)", + voicebot: "음성으로 고속도로 이용 AI 상담", +}; + +export const metadata: Metadata = { + title: titles[uiMode] ?? titles.app, + description: descriptions[uiMode] ?? descriptions.app, +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + viewportFit: "cover", + themeColor: uiMode === "app" ? "#b2c7d9" : "#246beb", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + const htmlClass = + uiMode === "ai" + ? "ai-mode" + : uiMode === "banner" + ? "banner-mode" + : uiMode === "voicebot" + ? "voicebot-mode" + : undefined; + const bodyAttr = + uiMode === "ai" + ? "ai-mode" + : uiMode === "banner" + ? "banner-mode" + : uiMode === "voicebot" + ? "voicebot-mode" + : "next-sse"; + + return ( + + {children} + + ); +} diff --git a/chatbotApp-chatbot2.0-agent/web/src/app/page.tsx b/chatbotApp-chatbot2.0-agent/web/src/app/page.tsx new file mode 100644 index 0000000..2515c8c --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/src/app/page.tsx @@ -0,0 +1,13 @@ +import { AiChatApp } from "@/components/ai/AiChatApp"; +import { BannerChatApp } from "@/components/banner/BannerChatApp"; +import { ChatApp } from "@/components/ChatApp"; +import { VoicebotChatApp } from "@/components/voicebot/VoicebotChatApp"; +import { getUiMode } from "@/lib/uiMode"; + +export default function Page() { + const mode = getUiMode(); + if (mode === "banner") return ; + if (mode === "ai") return ; + if (mode === "voicebot") return ; + return ; +} diff --git a/chatbotApp-chatbot2.0-agent/web/src/components/ActionButtonList.tsx b/chatbotApp-chatbot2.0-agent/web/src/components/ActionButtonList.tsx new file mode 100644 index 0000000..f7c9cb5 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/src/components/ActionButtonList.tsx @@ -0,0 +1,43 @@ +import type { WebActionButton } from "@/lib/types"; + +interface ActionButtonListProps { + buttons?: WebActionButton[]; +} + +function resolveHref(button: WebActionButton): string | undefined { + if (button.action === "phone") { + const number = button.phoneNumber?.replace(/[^0-9+]/g, ""); + return number ? `tel:${number}` : button.url; + } + return button.url || button.webUrl || button.pcUrl || button.mobileUrl || button.androidUrl || button.iosUrl; +} + +function isExternalHref(href: string): boolean { + return /^https?:\/\//i.test(href); +} + +export function ActionButtonList({ buttons }: ActionButtonListProps) { + const resolvedButtons = (buttons ?? []) + .map((button) => ({ button, href: resolveHref(button) })) + .filter((item): item is { button: WebActionButton; href: string } => Boolean(item.href)); + + if (resolvedButtons.length === 0) { + return null; + } + + return ( +
+ {resolvedButtons.map(({ button, href }, index) => ( + + {button.label || "바로가기"} + + ))} +
+ ); +} diff --git a/chatbotApp-chatbot2.0-agent/web/src/components/ChatApp.tsx b/chatbotApp-chatbot2.0-agent/web/src/components/ChatApp.tsx new file mode 100644 index 0000000..e3e93f1 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/src/components/ChatApp.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { ExBrandTitle } from "@/components/ExBrandTitle"; +import { ChatCloseButton } from "@/components/ChatCloseButton"; +import { Composer } from "@/components/Composer"; +import { FaqPanel } from "@/components/FaqPanel"; +import { MessageList } from "@/components/MessageList"; +import { RelatedQuestionsBar } from "@/components/RelatedQuestionsBar"; +import { useEmbedShell } from "@/hooks/useEmbedShell"; +import { useChatSession } from "@/hooks/useChatSession"; +import { getLatestAssistantQuickReplies } from "@/lib/relatedQuestions"; +import { WELCOME_TEXT } from "@/lib/config"; + +export function ChatApp() { + const { + messages, + busy, + faqOpen, + faqLoading, + faqCategories, + faqSupported, + sendMessage, + toggleFaq, + setFaqOpen, + } = useChatSession(); + + const embedded = useEmbedShell(); + const hasConversation = messages.length > 0; + const relatedQuestions = getLatestAssistantQuickReplies(messages); + + return ( +
+
+ + {embedded ? ( +
+ +
+ ) : null} +
+ void sendMessage(text)} /> + {hasConversation && relatedQuestions.length > 0 ? ( + void sendMessage(text)} + /> + ) : null} + void sendMessage(text)} + onFaqToggle={toggleFaq} + /> + setFaqOpen(false)} + onSelect={(question) => void sendMessage(question)} + /> +
+ ); +} diff --git a/chatbotApp-chatbot2.0-agent/web/src/components/ChatCloseButton.tsx b/chatbotApp-chatbot2.0-agent/web/src/components/ChatCloseButton.tsx new file mode 100644 index 0000000..ca88ce1 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/src/components/ChatCloseButton.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { requestChatClose } from "@/lib/embedShell"; + +interface ChatCloseButtonProps { + className?: string; + label?: string; +} + +export function ChatCloseButton({ + className = "ai-btn-close", + label = "종료", +}: ChatCloseButtonProps) { + return ( + + ); +} diff --git a/chatbotApp-chatbot2.0-agent/web/src/components/Composer.tsx b/chatbotApp-chatbot2.0-agent/web/src/components/Composer.tsx new file mode 100644 index 0000000..6266504 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/src/components/Composer.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { FormEvent, useCallback, useEffect, useRef, useState } from "react"; + +interface ComposerProps { + busy: boolean; + faqEnabled?: boolean; + onSend: (text: string) => void; + onFaqToggle: () => void; +} + +export function Composer({ busy, faqEnabled = true, onSend, onFaqToggle }: ComposerProps) { + const [value, setValue] = useState(""); + const [listening, setListening] = useState(false); + const recognitionRef = useRef(null); + const voiceBaseRef = useRef(""); + + const speechSupported = + typeof window !== "undefined" && + !!(window.SpeechRecognition || window.webkitSpeechRecognition); + + useEffect(() => { + if (!speechSupported) return; + const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition; + if (!Ctor) return; + const recognition = new Ctor(); + recognition.lang = "ko-KR"; + recognition.interimResults = true; + recognition.continuous = false; + recognition.maxAlternatives = 1; + + recognition.onstart = () => setListening(true); + recognition.onend = () => setListening(false); + recognition.onerror = () => setListening(false); + recognition.onresult = (event: SpeechRecognitionEvent) => { + let transcript = ""; + for (let i = event.resultIndex; i < event.results.length; i += 1) { + transcript += event.results[i][0].transcript; + } + setValue(voiceBaseRef.current + transcript.trim()); + }; + + recognitionRef.current = recognition; + return () => { + try { + recognition.stop(); + } catch { + /* noop */ + } + }; + }, [speechSupported]); + + const submit = useCallback( + (e?: FormEvent) => { + e?.preventDefault(); + const text = value.trim(); + if (!text || busy) return; + onSend(text); + setValue(""); + voiceBaseRef.current = ""; + }, + [busy, onSend, value], + ); + + const toggleVoice = () => { + const recognition = recognitionRef.current; + if (!recognition || busy) return; + if (listening) { + recognition.stop(); + return; + } + voiceBaseRef.current = value.trim() ? `${value.trim()} ` : ""; + try { + recognition.start(); + } catch { + /* noop */ + } + }; + + return ( +
+
+ setValue(e.target.value)} + /> +
+ {faqEnabled ? ( + + ) : null} +
+ +
+
+ ); +} + +declare global { + interface Window { + SpeechRecognition: typeof SpeechRecognition; + webkitSpeechRecognition: typeof SpeechRecognition; + } +} diff --git a/chatbotApp-chatbot2.0-agent/web/src/components/ExBrandTitle.tsx b/chatbotApp-chatbot2.0-agent/web/src/components/ExBrandTitle.tsx new file mode 100644 index 0000000..fa2ab8d --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/src/components/ExBrandTitle.tsx @@ -0,0 +1,38 @@ +import { CHATBOT_BRAND_SUFFIX } from "@/lib/brand"; +import "@/styles/ex-brand.css"; + +type ExBrandTitleProps = { + as?: "h1" | "h2" | "div" | "span"; + className?: string; + size?: "md" | "sm"; + variant?: "light" | "dark"; + showDivider?: boolean; +}; + +export function ExBrandTitle({ + as: Tag = "div", + className = "", + size = "md", + variant = "light", + showDivider = true, +}: ExBrandTitleProps) { + const classes = [ + "ex-brand", + variant === "dark" ? "ex-brand--dark" : "", + size === "sm" ? "ex-brand--sm" : "", + className, + ] + .filter(Boolean) + .join(" "); + + return ( + + + {showDivider ? + ); +} diff --git a/chatbotApp-chatbot2.0-agent/web/src/components/FaqPanel.tsx b/chatbotApp-chatbot2.0-agent/web/src/components/FaqPanel.tsx new file mode 100644 index 0000000..910eb0a --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/src/components/FaqPanel.tsx @@ -0,0 +1,50 @@ +import type { FaqCategory } from "@/lib/types"; + +interface FaqPanelProps { + open: boolean; + loading: boolean; + categories: FaqCategory[]; + onClose: () => void; + onSelect: (question: string) => void; +} + +export function FaqPanel({ open, loading, categories, onClose, onSelect }: FaqPanelProps) { + return ( + + ); +} diff --git a/chatbotApp-chatbot2.0-agent/web/src/components/FareResultCard.tsx b/chatbotApp-chatbot2.0-agent/web/src/components/FareResultCard.tsx new file mode 100644 index 0000000..99ef553 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/src/components/FareResultCard.tsx @@ -0,0 +1,94 @@ +import { formatFareWon, type ParsedFareResult } from "@/lib/fareResultCard"; +import "@/styles/fare-result-card.css"; + +interface FareResultCardProps { + data: ParsedFareResult; +} + +function SectionIcon({ kind }: { kind: "kec" | "private" }) { + return ( + + ); +} + +function VehicleIcon({ imageUrl, label }: { imageUrl?: string; label: string }) { + if (imageUrl) { + return ; + } + return ( + + ); +} + +export function FareResultCard({ data }: FareResultCardProps) { + const showPrivateSection = data.privateFare > 0; + + return ( +
+

{data.introText}

+ +
+ 총 통행료 + {formatFareWon(data.primaryFare)} + {data.routeLabel} +
+ +
+
+
+ + 도로공사 구간 요금 +
+ {formatFareWon(data.kecFare)} +
+ {showPrivateSection ? ( +
+
+ + 민자도로 구간 요금 +
+ + {formatFareWon(data.privateFare)} + +
+ ) : null} +
+ +
+

차종별 요금 안내

+
    + {data.vehicles.map((vehicle) => ( +
  • +
    +
    + + {vehicle.typeLabel} +
    + + {formatFareWon(vehicle.amount)} + +
    + {vehicle.privateAmount > 0 ? ( +

    + 도로공사 {formatFareWon(vehicle.kec)} · 민자 {formatFareWon(vehicle.privateAmount)} +

    + ) : null} +
  • + ))} +
+
+ +

* 차종별 편도 기준 (1종 승용차 기준)

+
+ ); +} diff --git a/chatbotApp-chatbot2.0-agent/web/src/components/ListCardStack.tsx b/chatbotApp-chatbot2.0-agent/web/src/components/ListCardStack.tsx new file mode 100644 index 0000000..52527dd --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/src/components/ListCardStack.tsx @@ -0,0 +1,104 @@ +import { FareResultCard } from "@/components/FareResultCard"; +import { + isFareListCard, + parseFareListCard, +} from "@/lib/fareResultCard"; +import { + LIST_CARD_ICON, + LIST_CARD_LABEL, + listCardItemKindFromImageUrl, + type WebListCard, +} from "@/lib/listCards"; + +interface ListCardPanelProps { + card: WebListCard; +} + +export function ListCardPanel({ card }: ListCardPanelProps) { + if (!card.items?.length) { + return null; + } + + return ( +
+ {card.title ?

{card.title}

: null} +
    + {card.items.map((item, index) => { + const kind = listCardItemKindFromImageUrl(item.imageUrl); + const label = LIST_CARD_LABEL[kind]; + const fallbackIcon = LIST_CARD_ICON[kind]; + const key = `${item.title}-${item.description ?? ""}-${index}`; + const content = ( + <> +
    +
    {item.title}
    + {item.description ? ( +
    {item.description}
    + ) : null} +
    + + + ); + + return ( +
  • + {item.linkUrl ? ( + + {content} + + ) : ( + content + )} +
  • + ); + })} +
+
+ ); +} + +interface ListCardStackProps { + cards?: WebListCard[]; +} + +export function ListCardStack({ cards }: ListCardStackProps) { + if (!cards?.length) { + return null; + } + + const hasFare = cards.some(isFareListCard); + const stackClass = hasFare ? "list-card-stack list-card-stack--fare" : "list-card-stack"; + + return ( +
+ {cards.map((card, index) => { + if (isFareListCard(card)) { + const parsed = parseFareListCard(card); + if (parsed) { + return ; + } + } + return ; + })} +
+ ); +} diff --git a/chatbotApp-chatbot2.0-agent/web/src/components/MarkdownBody.tsx b/chatbotApp-chatbot2.0-agent/web/src/components/MarkdownBody.tsx new file mode 100644 index 0000000..97b394d --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/src/components/MarkdownBody.tsx @@ -0,0 +1,118 @@ +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +interface MarkdownBodyProps { + text: string; + onSuggestionSelect?: (question: string) => void; +} + +const SUGGESTION_MARKER = "혹시 이런 것을 찾으셨나요"; +const SUGGESTION_LINE = /^\s*(\d+)[.)]\s*(.+?)\s*$/; + +type MarkdownSection = + | { type: "markdown"; text: string } + | { type: "suggestions"; title: string; items: { index: string; question: string }[]; footer?: string }; + +function parseSuggestionSections(text: string): MarkdownSection[] { + const lines = text.split(/\r?\n/); + const sections: MarkdownSection[] = []; + let markdownLines: string[] = []; + let suggestionTitle = ""; + let suggestionItems: { index: string; question: string }[] = []; + let suggestionFooter: string[] = []; + let inSuggestions = false; + + const flushMarkdown = () => { + const value = markdownLines.join("\n").trim(); + if (value) sections.push({ type: "markdown", text: value }); + markdownLines = []; + }; + + const flushSuggestions = () => { + if (suggestionItems.length > 0) { + sections.push({ + type: "suggestions", + title: suggestionTitle || "💡 혹시 이런 것을 찾으셨나요?", + items: suggestionItems, + footer: suggestionFooter.join("\n").trim() || undefined, + }); + } + suggestionTitle = ""; + suggestionItems = []; + suggestionFooter = []; + }; + + for (const line of lines) { + const trimmed = line.trim(); + if (!inSuggestions && trimmed.includes(SUGGESTION_MARKER)) { + flushMarkdown(); + inSuggestions = true; + suggestionTitle = trimmed; + continue; + } + + if (inSuggestions) { + const match = trimmed.match(SUGGESTION_LINE); + if (match) { + suggestionItems.push({ index: match[1], question: match[2] }); + continue; + } + if (trimmed) { + suggestionFooter.push(line); + } + continue; + } + + markdownLines.push(line); + } + + if (inSuggestions) { + flushSuggestions(); + } else { + flushMarkdown(); + } + + return sections; +} + +export function MarkdownBody({ text, onSuggestionSelect }: MarkdownBodyProps) { + const sections = parseSuggestionSections(text); + + return ( +
+ {sections.map((section, index) => { + if (section.type === "markdown") { + return ( + + {section.text} + + ); + } + + return ( +
+

{section.title}

+
    + {section.items.map((item) => ( +
  1. + +
  2. + ))} +
+ {section.footer ? ( + {section.footer} + ) : null} +
+ ); + })} +
+ ); +} diff --git a/chatbotApp-chatbot2.0-agent/web/src/components/MessageBubble.tsx b/chatbotApp-chatbot2.0-agent/web/src/components/MessageBubble.tsx new file mode 100644 index 0000000..cc06fa2 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/src/components/MessageBubble.tsx @@ -0,0 +1,55 @@ +import { ListCardStack } from "@/components/ListCardStack"; +import { ExBrandTitle } from "@/components/ExBrandTitle"; +import { hasFareListCards } from "@/lib/fareResultCard"; +import type { ChatMessage } from "@/lib/types"; +import { MarkdownBody } from "./MarkdownBody"; + +interface MessageBubbleProps { + message: ChatMessage; + onSuggestionSelect?: (question: string) => void; +} + +export function MessageBubble({ message, onSuggestionSelect }: MessageBubbleProps) { + if (message.role === "user") { + return ( +
+
+
{message.text}
+
{message.time}
+
+
+ ); + } + + const showFareCard = !message.streaming && hasFareListCards(message.listCards); + const showText = Boolean(message.text) && !showFareCard; + + return ( +
+ +
+ + {(message.streaming && !message.text && !message.listCards?.length) || showText ? ( +
+ {message.streaming && !message.text && !message.listCards?.length ? ( + + {message.statusText || "답변준비중…"} +
+ ) : null} + {!message.streaming ? : null} +
{message.time}
+
+
+ ); +} diff --git a/chatbotApp-chatbot2.0-agent/web/src/components/MessageList.tsx b/chatbotApp-chatbot2.0-agent/web/src/components/MessageList.tsx new file mode 100644 index 0000000..00b8b98 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/src/components/MessageList.tsx @@ -0,0 +1,28 @@ +import { useEffect, useRef } from "react"; +import type { ChatMessage } from "@/lib/types"; +import { MessageBubble } from "./MessageBubble"; + +interface MessageListProps { + messages: ChatMessage[]; + welcome: string; + onSuggestionSelect?: (question: string) => void; +} + +export function MessageList({ messages, welcome, onSuggestionSelect }: MessageListProps) { + const chatRef = useRef(null); + + useEffect(() => { + const el = chatRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + }, [messages]); + + return ( +
+
{welcome}
+ {messages.map((message) => ( + + ))} +
+ ); +} diff --git a/chatbotApp-chatbot2.0-agent/web/src/components/QuickReplyChips.tsx b/chatbotApp-chatbot2.0-agent/web/src/components/QuickReplyChips.tsx new file mode 100644 index 0000000..3969828 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/src/components/QuickReplyChips.tsx @@ -0,0 +1,34 @@ +"use client"; + +import type { WebQuickReply } from "@/lib/types"; + +interface QuickReplyChipsProps { + items?: WebQuickReply[]; + disabled?: boolean; + onSelect: (message: string) => void; +} + +export function QuickReplyChips({ items, disabled, onSelect }: QuickReplyChipsProps) { + if (!items?.length) { + return null; + } + + return ( +
+

이런 질문도 있어요

+
+ {items.map((item) => ( + + ))} +
+
+ ); +} diff --git a/chatbotApp-chatbot2.0-agent/web/src/components/RelatedQuestionsBar.tsx b/chatbotApp-chatbot2.0-agent/web/src/components/RelatedQuestionsBar.tsx new file mode 100644 index 0000000..ed49fd4 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/src/components/RelatedQuestionsBar.tsx @@ -0,0 +1,43 @@ +import type { WebQuickReply } from "@/lib/types"; + +interface RelatedQuestionsBarProps { + items: WebQuickReply[]; + busy: boolean; + onSelect: (message: string) => void; + variant?: "ai" | "app"; +} + +export function RelatedQuestionsBar({ + items, + busy, + onSelect, + variant = "ai", +}: RelatedQuestionsBarProps) { + if (!items.length) { + return null; + } + + const isAi = variant === "ai"; + + return ( + + ); +} diff --git a/chatbotApp-chatbot2.0-agent/web/src/components/ai/AiChatApp.tsx b/chatbotApp-chatbot2.0-agent/web/src/components/ai/AiChatApp.tsx new file mode 100644 index 0000000..38effa4 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/src/components/ai/AiChatApp.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useEffect } from "react"; +import "@/styles/ai-mode.css"; +import { FaqPanel } from "@/components/FaqPanel"; +import { useChatSession } from "@/hooks/useChatSession"; +import { ChatCloseButton } from "@/components/ChatCloseButton"; +import { ExBrandTitle } from "@/components/ExBrandTitle"; +import { RelatedQuestionsBar } from "@/components/RelatedQuestionsBar"; +import { useEmbedShell } from "@/hooks/useEmbedShell"; +import { getLatestAssistantQuickReplies } from "@/lib/relatedQuestions"; +import { AiComposer } from "./AiComposer"; +import { AiMessageList } from "./AiMessageList"; + +export function AiChatApp() { + const { + messages, + busy, + faqOpen, + faqLoading, + faqCategories, + faqSupported, + sendMessage, + toggleFaq, + loadFaq, + setFaqOpen, + } = useChatSession(); + + const embedded = useEmbedShell(); + const hasConversation = messages.length > 0; + const relatedQuestions = getLatestAssistantQuickReplies(messages); + + useEffect(() => { + if (faqSupported) void loadFaq(); + }, [faqSupported, loadFaq]); + + return ( +
+
+ +
+ {faqSupported ? ( + + ) : null} + +
+
+ + void sendMessage(text)} + /> + + {hasConversation && relatedQuestions.length > 0 ? ( + void sendMessage(question)} + /> + ) : null} + + void sendMessage(text)} /> + + setFaqOpen(false)} + onSelect={(question) => void sendMessage(question)} + /> +
+ ); +} diff --git a/chatbotApp-chatbot2.0-agent/web/src/components/ai/AiComposer.tsx b/chatbotApp-chatbot2.0-agent/web/src/components/ai/AiComposer.tsx new file mode 100644 index 0000000..b7ca612 --- /dev/null +++ b/chatbotApp-chatbot2.0-agent/web/src/components/ai/AiComposer.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { FormEvent, KeyboardEvent, useCallback, useRef, useState } from "react"; +import { useSpeechRecognition } from "@/hooks/useSpeechRecognition"; +import { AiVoiceOverlay } from "./AiVoiceOverlay"; + +interface AiComposerProps { + busy: boolean; + showFooterNote?: boolean; + onSend: (text: string) => void; +} + +export function AiComposer({ busy, showFooterNote = true, onSend }: AiComposerProps) { + const [value, setValue] = useState(""); + const textareaRef = useRef(null); + + const resizeTextarea = useCallback(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 120)}px`; + }, []); + + const handleVoiceComplete = useCallback( + (text: string) => { + setValue(text); + requestAnimationFrame(resizeTextarea); + textareaRef.current?.focus(); + }, + [resizeTextarea], + ); + + const { active: voiceActive, interimText, speaking, error: voiceError, start, cancel, supported } = + useSpeechRecognition({ onComplete: handleVoiceComplete }); + + const submit = useCallback( + (e?: FormEvent) => { + e?.preventDefault(); + const text = value.trim(); + if (!text || busy) return; + onSend(text); + setValue(""); + requestAnimationFrame(resizeTextarea); + }, + [busy, onSend, resizeTextarea, value], + ); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + submit(); + } + }; + + const openVoiceInput = () => { + if (busy || voiceActive) return; + start(value); + }; + + const canSend = value.trim().length > 0 && !busy; + + return ( + <> +