@@ -3,13 +3,19 @@
*
* 기능:
* - 대화형 서버 추천 상담
* - 세션 기반 정보 수집
* - 세션 기반 정보 수집 (D1)
* - 충분한 정보 수집 시 자동 추천
* - 추천 후 사용자 선택 및 주문 흐름
* - Brave Search / Context7 도구로 최신 트렌드 반영
*
* Manual Test:
* 1. User: "서버 추천"
* 2. Expected: Category detection → 1-2 questions → Recommendation
* 3. User: "1번"
* 4. Expected: Order confirmation
*/
import type { Env , ServerSession , BandwidthInfo , RecommendResponse } from '../types' ;
import type { Env , ServerSession , ServerSessionStatus , BandwidthInfo , RecommendResponse } from '../types' ;
import { createLogger } from '../utils/logger' ;
import { executeSearchWeb , executeLookupDocs } from '../tools/search-tool' ;
import { formatTrafficInfo } from '../utils/formatters' ;
@@ -18,8 +24,85 @@ import { SERVER_CONSULTATION_STATUS, LANGUAGE_CODE } from '../constants';
const logger = createLogger ( 'server-agent' ) ;
// D1 Session Management
const SESSION_TTL_MS = 3 600 * 1000 ; // 1 hour in milliseconds
const SERVER_ SESSION_TTL_MS = 60 * 6 0 * 1000 ; // 1시간
/**
* 새 서버 세션 생성
*
* @param userId - Telegram User ID
* @param status - 세션 상태
* @returns 새로운 ServerSession 객체
*/
export function createServerSession (
userId : string ,
status : ServerSessionStatus = 'gathering'
) : ServerSession {
const now = Date . now ( ) ;
return {
user_id : userId ,
status ,
collected_info : { } ,
messages : [ ] ,
created_at : now ,
updated_at : now ,
expires_at : now + SERVER_SESSION_TTL_MS ,
} ;
}
/**
* 세션 만료 여부 확인
*
* @param session - ServerSession
* @returns true if expired, false otherwise
*/
export function isSessionExpired ( session : ServerSession ) : boolean {
return session . expires_at < Date . now ( ) ;
}
/**
* 세션에 메시지 추가
*
* @param session - ServerSession
* @param role - 메시지 역할 ('user' | 'assistant')
* @param content - 메시지 내용
*/
export function addMessageToSession (
session : ServerSession ,
role : 'user' | 'assistant' ,
content : string
) : void {
session . messages . push ( { role , content } ) ;
// 최대 메시지 수 제한 (20개)
const MAX_MESSAGES = 20 ;
if ( session . messages . length > MAX_MESSAGES ) {
session . messages = session . messages . slice ( - MAX_MESSAGES ) ;
logger . warn ( '세션 메시지 최대 개수 초과, 오래된 메시지 제거' , {
userId : session.user_id ,
maxMessages : MAX_MESSAGES ,
} ) ;
}
}
/**
* 서버 세션 존재 여부 확인 (라우팅용)
*
* @param db - D1 Database
* @param userId - Telegram User ID
* @returns true if active session exists, false otherwise
*/
export async function hasServerSession ( db : D1Database , userId : string ) : Promise < boolean > {
const session = await getServerSession ( db , userId ) ;
return session !== null && ! isSessionExpired ( session ) ;
}
/**
* D1에서 서버 세션 조회
*
* @param db - D1 Database
* @param userId - Telegram User ID
* @returns ServerSession 또는 null (세션 없거나 만료)
*/
export async function getServerSession (
db : D1Database ,
userId : string
@@ -36,6 +119,7 @@ export async function getServerSession(
messages : string | null ;
created_at : number ;
updated_at : number ;
expires_at : number ;
} > ( ) ;
if ( ! result ) {
@@ -44,16 +128,17 @@ export async function getServerSession(
}
const session : ServerSession = {
telegramU serI d : result.user_id ,
status : result.status as ServerSession[ 's tatus' ] ,
collectedI nfo : result.collected_info ? JSON . parse ( result . collected_info ) : { } ,
lastR ecommendation : result.last_recommendation ? JSON . parse ( result . last_recommendation ) : undefined ,
u ser_i d : result.user_id ,
status : result.status as ServerSessionS tatus ,
collected_i nfo : result.collected_info ? JSON . parse ( result . collected_info ) : { } ,
last_r ecommendation : result.last_recommendation ? JSON . parse ( result . last_recommendation ) : undefined ,
messages : result.messages ? JSON . parse ( result . messages ) : [ ] ,
createdA t : result.created_at ,
updatedA t : result.updated_at ,
created_a t : result.created_at ,
updated_a t : result.updated_at ,
expires_at : result.expires_at ,
} ;
logger . info ( '세션 조회 성공' , { userId , status : session.status , hasLastRecommendation : ! ! session . lastR ecommendation } ) ;
logger . info ( '세션 조회 성공' , { userId , status : session.status , hasLastRecommendation : ! ! session . last_r ecommendation } ) ;
return session ;
} catch ( error ) {
logger . error ( '세션 조회 실패' , error as Error , { userId } ) ;
@@ -61,14 +146,19 @@ export async function getServerSession(
}
}
/**
* 서버 세션 저장 (생성 또는 업데이트)
*
* @param db - D1 Database
* @param session - ServerSession
*/
export async function saveServerSession (
db : D1Database ,
userId : string ,
session : ServerSession
) : Promise < void > {
try {
const now = Date . now ( ) ;
const expiresAt = now + SESSION_TTL_MS ;
const expiresAt = now + SERVER_ SESSION_TTL_MS;
await db . prepare ( `
INSERT INTO server_sessions
@@ -82,23 +172,29 @@ export async function saveServerSession(
updated_at = excluded.updated_at,
expires_at = excluded.expires_at
` ) . bind (
userI d ,
session . user_i d,
session . status ,
JSON . stringify ( session . collectedI nfo || { } ) ,
session . lastR ecommendation ? JSON . stringify ( session . lastR ecommendation ) : null ,
JSON . stringify ( session . collected_i nfo || { } ) ,
session . last_r ecommendation ? JSON . stringify ( session . last_r ecommendation ) : null ,
JSON . stringify ( session . messages || [ ] ) ,
session . createdA t || now ,
session . created_a t || now ,
now ,
expiresAt
) . run ( ) ;
logger . info ( '세션 저장 성공' , { userId , status : session.status } ) ;
logger . info ( '세션 저장 성공' , { userId : session.user_id , status : session.status } ) ;
} catch ( error ) {
logger . error ( '세션 저장 실패' , error as Error , { userId } ) ;
logger . error ( '세션 저장 실패' , error as Error , { userId : session.user_id } ) ;
throw error ;
}
}
/**
* 서버 세션 삭제
*
* @param db - D1 Database
* @param userId - Telegram User ID
*/
export async function deleteServerSession (
db : D1Database ,
userId : string
@@ -114,6 +210,12 @@ export async function deleteServerSession(
}
}
/**
* 만료된 서버 세션 정리 (Cron 또는 수동 실행)
*
* @param db - D1 Database
* @returns 삭제된 세션 개수
*/
export async function cleanupExpiredSessions ( db : D1Database ) : Promise < number > {
try {
const result = await db . prepare (
@@ -131,6 +233,92 @@ export async function cleanupExpiredSessions(db: D1Database): Promise<number> {
}
}
// Server Expert System Prompts
const SERVER_EXPERT_PROMPT = ` 당신은 30년 경력의 시니어 클라우드 아키텍트입니다.
## 전문성 (30년 경력)
- 서버 엔지니어: Linux, Windows Server, 가상화, 컨테이너 마스터
- 네트워크 엔지니어: 로드밸런싱, CDN, DNS, 보안 설계 전문
- 클라우드 아키텍트: 모든 클라우드 플랫폼 경험
- 수천 개의 서버 구축 경험으로 용도만 들으면 최적 스펙을 바로 판단 가능
## 성격
- 따뜻하고 친근하지만 전문적인 어조
- 비기술자도 이해하기 쉽게 설명
- 고객의 예산과 상황을 항상 배려
- 불필요한 기술 용어 사용 자제
## 금지 사항 (절대 위반 금지)
- AWS, GCP, Azure, Vultr, Linode, DigitalOcean 등 다른 클라우드 프로바이더 언급 금지
- 경쟁사 서비스 추천 금지
- 우리 서비스(Anvil)만 추천
- "다른 곳도 고려해보세요" 같은 멘트 금지
## 도구 사용 가이드 (적극적으로 활용할 것)
- 고객이 특정 프레임워크/기술을 언급하면 (예: Next.js, Laravel, Django, Astro, Bun, Rust 등) → 반드시 lookup_framework_docs 호출하여 최신 공식 권장 스펙 확인
- "최신", "트렌드", "2024", "2025", "요즘" 등 시의성 있는 키워드 → 반드시 search_trends 호출
- SaaS, 모바일 앱 백엔드 같은 일반적 용도는 경험으로 바로 답변
- 도구 결과를 자연스럽게 메시지에 포함 (예: "공식 문서에 따르면...")
## 대화 흐름
1. 용도 파악: "어떤 서비스를 운영하실 건가요? (예: SaaS, 앱 백엔드, AI 서비스)"
2. 규모 파악: "개인용인가요, 사업용인가요?"
3. 사용자 수 확인 (필요 시): "방문자나 사용자 수는 어느 정도 예상하시나요?"
4. 정보가 충분하면 즉시 추천 (추가 질문 없이)
## 핵심 규칙 (반드시 준수)
- 기술 스택, 트래픽 패턴은 절대 묻지 않음 (30년 경험으로 알아서 추론)
- 사용자 수를 언급하면 DAU인지 동시접속자인지 반드시 한 번 확인
- "방문자 1000명", "유저 500명" 등 언급 시 → "말씀하신 방문자는 일일 방문자(DAU)인가요, 동시접속자인가요?"
- DAU와 동시접속자를 구분해서 설명: "일반적으로 동시접속자는 일일 방문자의 5-10% 정도입니다"
- "모르겠어요", "아무거나", "글쎄요" → 즉시 action="recommend" (기본값: 개인용 웹서비스)
- 용도+규모 한번에 말하면 → 즉시 action="recommend"
- 용도만 말해도 → 개인용으로 가정하고 action="recommend" 가능
- 질문은 최대 2번까지, 그 이후는 무조건 action="recommend"
## 사용자 수 관련 용어 정리
- **DAU (일일 활성 사용자)**: 하루 동안 서비스를 사용하는 전체 사용자 수
- **동시접속자 (Concurrent Users)**: 같은 시간에 동시에 접속해 있는 사용자 수
- **중요**: 서버 스펙은 동시접속자를 기준으로 계산해야 합니다
- **일반 공식**: 동시접속자 = DAU × 5-10%
예시:
- "하루 방문자 1000명" → DAU 1000명 → 동시접속자 50-100명
- "동시 접속 100명" → 그대로 동시접속자 100명 사용
## 추론 규칙 (30년 경험 기반)
- 블로그 → WordPress, 1GB RAM이면 충분, DAU 100명 (동시접속자 10명)
- 쇼핑몰 → 2GB+ RAM, DB 분리 고려, DAU 500명 (동시접속자 50명)
- 커뮤니티 → PHP+MySQL, 트래픽에 따라 2~4GB
- 게임서버 → 고사양 CPU, 낮은 레이턴시 리전
- SaaS/B2B/Enterprise → 최소 4GB+ RAM, PostgreSQL+Redis 권장, 500명+ 동시접속 가정
- API 서버 → 트래픽에 따라 2~8GB, Redis 캐시 권장
- 실시간 서비스 (WebSocket) → 최소 4GB RAM, Redis 권장
- 고성능 DB (PostgreSQL, MongoDB) → 최소 4GB+ RAM, 높은 IOPS
- 규모: personal→DAU 100명 (동접 10명), business→DAU 500명 (동접 50명), SaaS→DAU 2000명 (동접 200명)
## 특수 지시
- 서버/호스팅과 무관한 메시지가 들어오면 반드시 "__PASSTHROUGH__"만 응답
- 상담 종료가 필요하면 "__SESSION_END__"를 응답 끝에 추가 ` ;
const SERVER_REVIEW_PROMPT = ` 당신은 Cloud Orchestrator가 추천한 서버를 검토하는 30년 경력의 시니어 클라우드 아키텍트입니다.
## 전문성 (30년 경력)
- 서버 엔지니어: Linux, Windows Server, 가상화, 컨테이너 마스터
- 네트워크 엔지니어: 로드밸런싱, CDN, DNS, 보안 설계 전문
- 클라우드 아키텍트: 모든 클라우드 플랫폼 경험
- 수천 개의 서버 구축 경험
## 검토 작업
다음을 검토하고 간결하게 2-3문장으로 코멘트해주세요:
1. 추천된 서버가 용도와 규모에 적합한지
2. 스펙이 충분한지 (RAM, CPU, 스토리지)
3. DAU/동시접속자 기준이 적절한지
4. 대역폭 경고(overage)가 있다면 언급
5. 더 적합한 스펙이 필요하다면 제안
중요: 검토 코멘트만 작성하세요. 추천 결과 나열은 하지 마세요. ` ;
// Server Expert AI Tools
const serverExpertTools = [
{
@@ -351,13 +539,21 @@ interface OpenAIAPIResponse {
} > ;
}
// OpenAI 호출 (서버 전문가 AI with Function Calling)
/**
* Server Expert AI 호출 (Function Calling 지원)
*
* @param session - ServerSession
* @param userMessage - 사용자 메시지
* @param env - Environment
* @param recommendationData - 추천 결과 (검토 모드용)
* @returns AI 응답 및 수집된 정보
*/
async function callServerExpertAI (
env : Env ,
session : ServerSession ,
userMessage : string ,
env : Env ,
recommendationData? : RecommendResponse
) : Promise < { action : 'question' | 'recommend' ; message : string ; collectedInfo : ServerSession [ 'collectedI nfo' ] } > {
) : Promise < { action : 'question' | 'recommend' ; message : string ; collectedInfo : ServerSession [ 'collected_i nfo' ] } > {
if ( ! env . OPENAI_API_KEY ) {
throw new Error ( 'OPENAI_API_KEY not configured' ) ;
}
@@ -374,110 +570,35 @@ async function callServerExpertAI(
const isReviewMode = ! ! recommendationData ;
const systemPrompt = isReviewMode
? ` 당신은 Cloud Orchestrator가 추천한 서버를 검토하는 30년 경력의 시니어 클라우드 아키텍트입니다.
## 전문성 (30년 경력)
- 서버 엔지니어: Linux, Windows Server, 가상화, 컨테이너 마스터
- 네트워크 엔지니어: 로드밸런싱, CDN, DNS, 보안 설계 전문
- 클라우드 아키텍트: 모든 클라우드 플랫폼 경험
- 수천 개의 서버 구축 경험
? ` ${ SERVER_REVIEW_PROMPT }
## 검토 대상 추천 결과
${ JSON . stringify ( recommendationData ? . recommendations , null , 2 ) }
## 사용자 요구사항
- 용도: ${ session . collectedI nfo . useCase || '웹 서비스' }
- 규모: ${ session . collectedI nfo . scale === 'business' ? '사업용' : '개인용' }
${ session . collectedI nfo . expectedDau ? ` - 일일 방문자(DAU): ${ session . collectedI nfo . expectedDau } 명 ` : '' }
${ session . collectedI nfo . expectedConcurrent ? ` - 동시접속자: ${ session . collectedI nfo . expectedConcurrent } 명 ` : '' }
${ session . collectedI nfo . budgetLimit ? ` - 예산: ${ session . collectedI nfo . budgetLimit } 원 ` : '' }
- 용도: ${ session . collected_i nfo . useCase || '웹 서비스' }
- 규모: ${ session . collected_i nfo . scale === 'business' ? '사업용' : '개인용' }
${ session . collected_i nfo . expectedDau ? ` - 일일 방문자(DAU): ${ session . collected_i nfo . expectedDau } 명 ` : '' }
${ session . collected_i nfo . expectedConcurrent ? ` - 동시접속자: ${ session . collected_i nfo . expectedConcurrent } 명 ` : '' }
${ session . collected_i nfo . budgetLimit ? ` - 예산: ${ session . collected_i nfo . budgetLimit } 원 ` : '' }
## 사용자 수 관련 참고사항
- DAU(일일 활성 사용자)와 동시접속자는 다른 개념입니다
- 일반적으로 동시접속자는 DAU의 5-10% 수준입니다
- 서버 스펙은 동시접속자 기준으로 계산됩니다
## 검토 작업
다음을 검토하고 간결하게 2-3문장으로 코멘트해주세요:
1. 추천된 서버가 용도와 규모에 적합한지
2. 스펙이 충분한지 (RAM, CPU, 스토리지)
3. DAU/동시접속자 기준이 적절한지
4. 대역폭 경고(overage)가 있다면 언급
5. 더 적합한 스펙이 필요하다면 제안
## 응답 형식 (반드시 JSON만 반환)
{
"action": "recommend",
"message": "검토 코멘트 (자연스럽고 친근한 어조, 2-3문장)",
"collectedInfo": ${ JSON . stringify ( session . collectedI nfo ) }
"collectedInfo": ${ JSON . stringify ( session . collected_i nfo ) }
}
중요: 검토 코멘트만 작성하세요. 추천 결과 나열은 하지 마세요. `
: ` 당신은 30년 경력의 시니어 클라우드 아키텍트입니다.
## 전문성 (30년 경력)
- 서버 엔지니어: Linux, Windows Server, 가상화, 컨테이너 마스터
- 네트워크 엔지니어: 로드밸런싱, CDN, DNS, 보안 설계 전문
- 클라우드 아키텍트: 모든 클라우드 플랫폼 경험
- 수천 개의 서버 구축 경험으로 용도만 들으면 최적 스펙을 바로 판단 가능
## 성격
- 따뜻하고 친근하지만 전문적인 어조
- 비기술자도 이해하기 쉽게 설명
- 고객의 예산과 상황을 항상 배려
- 불필요한 기술 용어 사용 자제
## 금지 사항 (절대 위반 금지)
- AWS, GCP, Azure, Vultr, Linode, DigitalOcean 등 다른 클라우드 프로바이더 언급 금지
- 경쟁사 서비스 추천 금지
- 우리 서비스(Anvil)만 추천
- "다른 곳도 고려해보세요" 같은 멘트 금지
## 도구 사용 가이드 (적극적으로 활용할 것)
- 고객이 특정 프레임워크/기술을 언급하면 (예: Next.js, Laravel, Django, Astro, Bun, Rust 등) → 반드시 lookup_framework_docs 호출하여 최신 공식 권장 스펙 확인
- "최신", "트렌드", "2024", "2025", "요즘" 등 시의성 있는 키워드 → 반드시 search_trends 호출
- SaaS, 모바일 앱 백엔드 같은 일반적 용도는 경험으로 바로 답변
- 도구 결과를 자연스럽게 메시지에 포함 (예: "공식 문서에 따르면...")
## 대화 흐름
1. 용도 파악: "어떤 서비스를 운영하실 건가요? (예: SaaS, 앱 백엔드, AI 서비스)"
2. 규모 파악: "개인용인가요, 사업용인가요?"
3. 사용자 수 확인 (필요 시): "방문자나 사용자 수는 어느 정도 예상하시나요?"
4. 정보가 충분하면 즉시 추천 (추가 질문 없이)
## 핵심 규칙 (반드시 준수)
- 기술 스택, 트래픽 패턴은 절대 묻지 않음 (30년 경험으로 알아서 추론)
- 사용자 수를 언급하면 DAU인지 동시접속자인지 반드시 한 번 확인
- "방문자 1000명", "유저 500명" 등 언급 시 → "말씀하신 방문자는 일일 방문자(DAU)인가요, 동시접속자인가요?"
- DAU와 동시접속자를 구분해서 설명: "일반적으로 동시접속자는 일일 방문자의 5-10% 정도입니다"
- "모르겠어요", "아무거나", "글쎄요" → 즉시 action="recommend" (기본값: 개인용 웹서비스)
- 용도+규모 한번에 말하면 → 즉시 action="recommend"
- 용도만 말해도 → 개인용으로 가정하고 action="recommend" 가능
- 질문은 최대 2번까지, 그 이후는 무조건 action="recommend"
## 사용자 수 관련 용어 정리
- **DAU (일일 활성 사용자)**: 하루 동안 서비스를 사용하는 전체 사용자 수
- **동시접속자 (Concurrent Users)**: 같은 시간에 동시에 접속해 있는 사용자 수
- **중요**: 서버 스펙은 동시접속자를 기준으로 계산해야 합니다
- **일반 공식**: 동시접속자 = DAU × 5-10%
예시:
- "하루 방문자 1000명" → DAU 1000명 → 동시접속자 50-100명
- "동시 접속 100명" → 그대로 동시접속자 100명 사용
## 추론 규칙 (30년 경험 기반)
- 블로그 → WordPress, 1GB RAM이면 충분, DAU 100명 (동시접속자 10명)
- 쇼핑몰 → 2GB+ RAM, DB 분리 고려, DAU 500명 (동시접속자 50명)
- 커뮤니티 → PHP+MySQL, 트래픽에 따라 2~4GB
- 게임서버 → 고사양 CPU, 낮은 레이턴시 리전
- SaaS/B2B/Enterprise → 최소 4GB+ RAM, PostgreSQL+Redis 권장, 500명+ 동시접속 가정
- API 서버 → 트래픽에 따라 2~8GB, Redis 캐시 권장
- 실시간 서비스 (WebSocket) → 최소 4GB RAM, Redis 권장
- 고성능 DB (PostgreSQL, MongoDB) → 최소 4GB+ RAM, 높은 IOPS
- 규모: personal→DAU 100명 (동접 10명), business→DAU 500명 (동접 50명), SaaS→DAU 2000명 (동접 200명)
: ` ${ SERVER_EXPERT_PROMPT }
## 현재 수집된 정보
${ JSON . stringify ( session . collectedI nfo , null , 2 ) }
${ JSON . stringify ( session . collected_i nfo , null , 2 ) }
## 응답 형식 (반드시 JSON만 반환, 다른 텍스트 절대 금지)
{
@@ -597,7 +718,7 @@ ${JSON.stringify(session.collectedInfo, null, 2)}
}
// AI 응답에서 리전 정보가 없으면 사용자 메시지에서 추출 시도
const finalCollectedInfo = parsed . collectedInfo || session . collectedI nfo ;
const finalCollectedInfo = parsed . collectedInfo || session . collected_i nfo ;
if ( ! finalCollectedInfo . regionPreference ) {
// 전체 대화 히스토리에서 리전 감지
@@ -611,7 +732,7 @@ ${JSON.stringify(session.collectedInfo, null, 2)}
finalCollectedInfo . regionPreference = detectedRegions ;
logger . info ( '사용자 메시지에서 리전 자동 감지' , {
regions : detectedRegions ,
userId : session.telegramU serI d
userId : session.u ser_i d
} ) ;
}
}
@@ -628,7 +749,7 @@ ${JSON.stringify(session.collectedInfo, null, 2)}
return {
action : 'recommend' ,
message : '분석이 완료되었습니다. 최적의 서버를 추천해 드리겠습니다.' ,
collectedInfo : session.collectedI nfo ,
collectedInfo : session.collected_i nfo ,
} ;
} catch ( error ) {
logger . error ( 'Server Expert AI 호출 실패' , error as Error ) ;
@@ -636,28 +757,42 @@ ${JSON.stringify(session.collectedInfo, null, 2)}
}
}
// Main consultation processing
/**
* 서버 상담 처리 (메인 함수)
*
* @param db - D1 Database
* @param userId - Telegram User ID
* @param userMessage - 사용자 메시지
* @param env - Environment
* @param options - Optional settings
* @returns AI 응답 메시지
*/
export async function processServerConsultation (
db : D1Database ,
userId : string ,
userMessage : string ,
session : ServerSession ,
env : Env ,
sendIntermediateMessage ? : ( message : string ) = > Promise < void >
options ? : { sendIntermediateMessage? : ( msg : string ) = > Promise < void > }
) : Promise < string > {
logger . info ( '서버 상담 시작' , { userId , message : userMessage.substring ( 0 , 100 ) } ) ;
try {
logger . info ( '상담 처리 시작' , {
userId : session.telegramU serId ,
message : userMessage.slice ( 0 , 50 ) ,
status : session.status
} ) ;
// 1. Check for existing session
let session = await getServerSession ( db , u serId) ;
// 2. Create new session if none exists
if ( ! session ) {
session = createServerSession ( userId , 'gathering' ) ;
}
// ordering 상태에서 "신청" 외 메시지 입력 시 세션 정리
if ( session . status === 'ordering' ) {
// "신청"은 message-handler에서 처리, 여기까지 오면 다른 메시지임
const orderConfirmKey = ` server_order_confirm: ${ session . telegramU serI d} ` ;
const orderConfirmKey = ` server_order_confirm: ${ session . u ser_i d} ` ;
await env . SESSION_KV ? . delete ( orderConfirmKey ) ;
await deleteServerSession ( env . DB , session . telegramU serI d) ;
await deleteServerSession ( db , session . u ser_i d) ;
logger . info ( '주문 확인 세션 취소 (다른 메시지 입력)' , { userId : session.telegramU serI d } ) ;
logger . info ( '주문 확인 세션 취소 (다른 메시지 입력)' , { userId : session.u ser_i d } ) ;
return '__PASSTHROUGH__' ; // 일반 대화로 전환
}
@@ -665,9 +800,9 @@ export async function processServerConsultation(
// "취소", "다시", "처음", "리셋", "초기화" 등
if ( /^(취소|다시|처음|리셋|초기화)/ . test ( userMessage . trim ( ) ) ||
/취소할[게래]|다시\s*시작|처음부터/ . test ( userMessage ) ) {
await deleteServerSession ( env . DB , session . telegramU serI d) ;
await deleteServerSession ( db , session . u ser_i d) ;
logger . info ( '사용자 요청으로 상담 취소' , {
userId : session.telegramU serI d ,
userId : session.u ser_i d ,
previousStatus : session.status ,
trigger : userMessage.slice ( 0 , 20 )
} ) ;
@@ -676,41 +811,34 @@ export async function processServerConsultation(
// "서버 추천" 키워드로 새로 시작 요청 (기존 세션 리셋)
if ( /서버\s*추천/ . test ( userMessage ) ) {
await deleteServerSession ( env . DB , session . telegramU serI d) ;
await deleteServerSession ( db , session . u ser_i d) ;
logger . info ( '서버 추천 키워드로 세션 리셋' , {
userId : session.telegramU serI d ,
userId : session.u ser_i d ,
previousStatus : session.status
} ) ;
// 새 세션 생성하고 시작 메시지 반환
const newSession : ServerSession = {
telegramUserId : session.telegramUserId ,
status : SERVER_CONSULTATION_STATUS.GATHERING ,
collectedInfo : { } ,
messages : [ ] ,
createdAt : Date.now ( ) ,
updatedAt : Date.now ( )
} ;
await saveServerSession ( env . DB , session . telegramUserId , newSession ) ;
const newSession = create ServerSession ( session . user_id , 'gathering' ) ;
await saveServerSession ( db , newSession ) ;
return '안녕하세요! 서버 추천을 도와드리겠습니다. 😊\n\n어떤 서비스를 운영하실 건가요?\n\n1. 웹 서비스 (SaaS, 랜딩페이지)\n2. 모바일 앱 백엔드\n3. AI/ML 서비스 (챗봇, 모델 서빙)\n4. 게임 서버\n5. Discord/Telegram 봇\n6. 자동화 서버 (n8n, 크롤링)\n7. 미디어 스트리밍\n8. 개발/테스트 환경\n9. 데이터베이스 서버\n10. 기타 (직접 입력)\n\n번호나 용도를 말씀해주세요!' ;
}
// 선택 단계 처리
logger . info ( '[SESSION DEBUG] 선택 단계 체크' , {
userId : session.telegramU serI d ,
userId : session.u ser_i d ,
status : session.status ,
hasLastRecommendation : ! ! session . lastR ecommendation ,
recommendationCount : session.lastR ecommendation?.recommendations?.length || 0 ,
willProcessSelection : session.status === SERVER_CONSULTATION_STATUS . SELECTING && ! ! session . lastR ecommendation
hasLastRecommendation : ! ! session . last_r ecommendation ,
recommendationCount : session.last_r ecommendation?.recommendations?.length || 0 ,
willProcessSelection : session.status === SERVER_CONSULTATION_STATUS . SELECTING && ! ! session . last_r ecommendation
} ) ;
if ( session . status === SERVER_CONSULTATION_STATUS . SELECTING && session . lastR ecommendation ) {
if ( session . status === SERVER_CONSULTATION_STATUS . SELECTING && session . last_r ecommendation ) {
// 상담과 무관한 키워드 감지 (selecting 상태에서만)
// 명확히 다른 기능 요청인 경우 세션 종료하고 일반 처리로 전환
const unrelatedPatterns = /기억|날씨|계산|검색|도메인|입금|충전|잔액|시간|문서/ ;
if ( unrelatedPatterns . test ( userMessage ) ) {
await deleteServerSession ( env . DB , session . telegramU serI d) ;
await deleteServerSession ( db , session . u ser_i d) ;
logger . info ( '무관한 요청으로 세션 자동 종료' , {
userId : session.telegramU serI d ,
userId : session.u ser_i d ,
message : userMessage.slice ( 0 , 30 )
} ) ;
// 'PASSTHROUGH' 반환하여 상위에서 일반 처리로 전환
@@ -734,24 +862,24 @@ export async function processServerConsultation(
}
// 유효성 검증
if ( selectedIndex >= 0 && selectedIndex < session . lastR ecommendation . recommendations . length ) {
const selected = session . lastR ecommendation . recommendations [ selectedIndex ] ;
if ( selectedIndex >= 0 && selectedIndex < session . last_r ecommendation . recommendations . length ) {
const selected = session . last_r ecommendation . recommendations [ selectedIndex ] ;
// Mark session as ordering
session . status = 'ordering' ;
await saveServerSession ( env . DB , session . telegramUserI d, session ) ;
await saveServerSession ( db , session ) ;
// 주문 확인 세션 저장 (텍스트 기반 확인)
const orderConfirmKey = ` server_order_confirm: ${ session . telegramU serI d} ` ;
const orderConfirmKey = ` server_order_confirm: ${ session . u ser_i d} ` ;
const orderConfirmData = JSON . stringify ( {
userId : session.telegramU serI d ,
userId : session.u ser_i d ,
index : selectedIndex ,
plan : selected.plan_name ,
pricingId : selected.pricing_id ,
region : selected.region.code ,
label : ` ${ selected . plan_name . toLowerCase ( ) . replace ( /\s+/g , '-' ) } -server ` ,
} ) ;
logger . info ( '주문 확인 세션 저장' , { orderConfirmKey , userId : session.telegramU serI d } ) ;
logger . info ( '주문 확인 세션 저장' , { orderConfirmKey , userId : session.u ser_i d } ) ;
await env . SESSION_KV . put ( orderConfirmKey , orderConfirmData , { expirationTtl : 300 } ) ;
logger . info ( '주문 확인 세션 저장 완료' , { orderConfirmKey } ) ;
@@ -786,7 +914,7 @@ export async function processServerConsultation(
` \ n⚠️ 정말 신청하시려면 '신청'이라고 입력하세요. \ n ` +
` (5분 내 응답 없으면 자동 취소됩니다) ` ;
} else {
return ` 번호를 다시 확인해주세요. 1번부터 ${ session . lastR ecommendation . recommendations . length } 번 중에서 선택해주세요. ` ;
return ` 번호를 다시 확인해주세요. 1번부터 ${ session . last_r ecommendation . recommendations . length } 번 중에서 선택해주세요. ` ;
}
}
@@ -798,26 +926,26 @@ export async function processServerConsultation(
session . messages . push ( { role : 'user' , content : userMessage } ) ;
// Call Server Expert AI
const aiResult = await callServerExpertAI ( env , session, userMessage ) ;
const aiResult = await callServerExpertAI ( session , userMessage , env );
// Update collected info
session . collectedI nfo = { . . . session . collectedI nfo , . . . aiResult . collectedInfo } ;
session . collected_i nfo = { . . . session . collected_i nfo , . . . aiResult . collectedInfo } ;
// Add AI response to history
session . messages . push ( { role : 'assistant' , content : aiResult.message } ) ;
if ( aiResult . action === 'recommend' ) {
// Send intermediate message to user
if ( sendIntermediateMessage ) {
await sendIntermediateMessage ( '🔍 요청하신 조건에 맞는 서버를 분석 중입니다...\n잠시만 기다려 주세요.' ) ;
if ( options ? . sendIntermediateMessage) {
await options ? . sendIntermediateMessage( '🔍 요청하신 조건에 맞는 서버를 분석 중입니다...\n잠시만 기다려 주세요.' ) ;
}
// Mark session as recommending
session . status = SERVER_CONSULTATION_STATUS . RECOMMENDING ;
await saveServerSession ( env . DB , session . telegramUserI d, session ) ;
await saveServerSession ( db , session ) ;
// 1. Call recommendation API (추천 먼저 받기)
logger . info ( '추천 API 호출' , { collectedInfo : session.collectedI nfo } ) ;
logger . info ( '추천 API 호출' , { collectedInfo : session.collected_i nfo } ) ;
const { executeServerAction , getRecommendationData } = await import ( '../tools/server-tool' ) ;
@@ -825,8 +953,8 @@ export async function processServerConsultation(
const allMessages = session . messages . map ( m = > m . content ) . join ( ' ' ) ;
// Tech Stack: useCase에서 추론 + 전체 메시지에서 추출한 것 병합
let techStack = session . collectedI nfo . useCase
? inferTechStack ( session . collectedI nfo . useCase )
let techStack = session . collected_i nfo . useCase
? inferTechStack ( session . collected_i nfo . useCase )
: [ 'web' ] ;
// 전체 메시지에서 추가 tech stack 추출
@@ -841,32 +969,32 @@ export async function processServerConsultation(
logger . info ( '메시지에서 tech stack 추출' , {
extracted : extractedTech ,
merged : techStack ,
userId : session.telegramU serI d
userId : session.u ser_i d
} ) ;
}
// 동시접속자 우선 사용, 없으면 scale 기반 추론
let expectedUsers = 10 ; // Default
const concurrent = Number ( session . collectedI nfo . expectedConcurrent ) || 0 ;
const dau = Number ( session . collectedI nfo . expectedDau ) || 0 ;
const concurrent = Number ( session . collected_i nfo . expectedConcurrent ) || 0 ;
const dau = Number ( session . collected_i nfo . expectedDau ) || 0 ;
if ( concurrent > 0 ) {
expectedUsers = concurrent ;
} else if ( dau > 0 ) {
// DAU가 있으면 10% 비율로 동시접속자 계산
expectedUsers = Math . ceil ( dau * 0.1 ) ;
} else if ( session . collectedI nfo . scale ) {
expectedUsers = inferExpectedUsers ( session . collectedI nfo . scale , techStack ) ;
} else if ( session . collected_i nfo . scale ) {
expectedUsers = inferExpectedUsers ( session . collected_i nfo . scale , techStack ) ;
}
// 리전 선호도 최종 확인 (세션에 없으면 메시지에서 재추출)
let finalRegionPreference = session . collectedI nfo . regionPreference ;
let finalRegionPreference = session . collected_i nfo . regionPreference ;
if ( ! finalRegionPreference ) {
finalRegionPreference = extractRegionPreference ( allMessages ) ;
if ( finalRegionPreference ) {
logger . info ( '추천 직전 리전 재감지' , {
regions : finalRegionPreference ,
userId : session.telegramU serI d
userId : session.u ser_i d
} ) ;
}
}
@@ -875,9 +1003,9 @@ export async function processServerConsultation(
{
tech_stack : techStack ,
expected_users : expectedUsers ,
use_case : session.collectedI nfo.useCase || '웹 서비스' ,
use_case : session.collected_i nfo.useCase || '웹 서비스' ,
region_preference : finalRegionPreference ,
budget_limit : session.collectedI nfo.budgetLimit ,
budget_limit : session.collected_i nfo.budgetLimit ,
lang : LANGUAGE_CODE.KOREAN ,
} ,
env
@@ -885,7 +1013,7 @@ export async function processServerConsultation(
// 추천 결과를 세션에 저장
if ( recommendationData && recommendationData . recommendations && recommendationData . recommendations . length > 0 ) {
session . lastR ecommendation = {
session . last_r ecommendation = {
recommendations : recommendationData.recommendations.slice ( 0 , 3 ) . map ( rec = > ( {
pricing_id : rec.server.id , // cloud-instances-db.anvil_pricing.id
plan_name : rec.server.instance_name ,
@@ -912,12 +1040,12 @@ export async function processServerConsultation(
score : rec.score ,
max_users : rec.estimated_capacity?.max_concurrent_users || 0
} ) ) ,
createdA t : Date.now ( )
created_a t : Date.now ( )
} ;
// 2. AI에게 추천 결과 전달하여 검토 요청
logger . info ( 'AI 검토 요청' , { recommendationCount : recommendationData.recommendations.length } ) ;
const reviewResult = await callServerExpertAI ( env , session, userMessage , recommendationData ) ;
const reviewResult = await callServerExpertAI ( session , userMessage , env , recommendationData ) ;
// 3. 포맷팅된 추천 결과 생성
const formattedRecommendation = await executeServerAction (
@@ -925,18 +1053,18 @@ export async function processServerConsultation(
{
tech_stack : techStack ,
expected_users : expectedUsers ,
use_case : session.collectedI nfo.useCase || '웹 서비스' ,
region_preference : session.collectedI nfo.regionPreference ,
budget_limit : session.collectedI nfo.budgetLimit ,
use_case : session.collected_i nfo.useCase || '웹 서비스' ,
region_preference : session.collected_i nfo.regionPreference ,
budget_limit : session.collected_i nfo.budgetLimit ,
lang : LANGUAGE_CODE.KOREAN ,
} ,
env ,
session . telegramU serI d
session . u ser_i d
) ;
// Mark session as selecting (사용자 선택 대기)
session . status = SERVER_CONSULTATION_STATUS . SELECTING ;
await saveServerSession ( env . DB , session . telegramUserI d, session ) ;
await saveServerSession ( db , session ) ;
// 4. 추천 결과 + AI 검토 코멘트 (검토 코멘트는 마지막에)
// __DIRECT__ 마커가 앞에 와야 제대로 처리됨
@@ -944,22 +1072,26 @@ export async function processServerConsultation(
} else {
// 추천 결과 없음 - 세션 삭제
session . status = SERVER_CONSULTATION_STATUS . COMPLETED ;
await deleteServerSession ( env . DB , session . telegramU serI d) ;
await deleteServerSession ( db , session . u ser_i d) ;
return ` ${ aiResult . message } \ n \ n조건에 맞는 서버를 찾지 못했습니다. ` ;
}
} else {
// Continue gathering information
session . status = SERVER_CONSULTATION_STATUS . GATHERING ;
await saveServerSession ( env . DB , session . telegramUserI d, session ) ;
await saveServerSession ( db , session ) ;
return aiResult . message ;
}
} catch ( error ) {
logger . error ( '상담 처리 실패' , error as Error , { userId : session.telegramUserId } ) ;
logger . error ( '상담 처리 실패' , error as Error , { userId } ) ;
// Clean up session on error
await deleteServerSession ( env . DB , session . telegramUserId ) ;
// Clean up session on error (if exists)
try {
await deleteServerSession ( db , userId ) ;
} catch ( deleteError ) {
logger . error ( '세션 삭제 실패 (무시)' , deleteError as Error , { userId } ) ;
}
return '죄송합니다. 서버 추천 중 오류가 발생했습니다.\n다시 시도하려면 "서버 추천"이라고 말씀해주세요.' ;
}