refactor: apply SessionManager to all agents

- domain-agent: use DomainSessionManager (handles target_domain)
- deposit-agent: use SessionManager<DepositSession>
- troubleshoot-agent: use SessionManager<TroubleshootSession>
- server-agent: use ServerSessionManager (handles last_recommendation)
- Remove ~760 lines of duplicated session CRUD code
- Use centralized constants from agent-config.ts
- Import OpenAI types from types.ts instead of local definitions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-02-05 11:44:06 +09:00
parent ffd310c903
commit edf1bbc9a2
4 changed files with 57 additions and 819 deletions

View File

@@ -17,7 +17,9 @@
import { createLogger } from '../utils/logger'; import { createLogger } from '../utils/logger';
import { executeWithOptimisticLock, OptimisticLockError } from '../utils/optimistic-lock'; import { executeWithOptimisticLock, OptimisticLockError } from '../utils/optimistic-lock';
import { TRANSACTION_STATUS, TRANSACTION_TYPE } from '../constants'; import { TRANSACTION_STATUS, TRANSACTION_TYPE } from '../constants';
import type { Env, ManageDepositArgs, DepositFunctionResult, DepositSession, DepositSessionStatus } from '../types'; import type { Env, ManageDepositArgs, DepositFunctionResult, DepositSession, DepositSessionStatus, OpenAIToolCall, OpenAIMessage, OpenAIAPIResponse } from '../types';
import { SessionManager } from '../utils/session-manager';
import { getSessionConfig } from '../constants/agent-config';
const logger = createLogger('deposit-agent'); const logger = createLogger('deposit-agent');
@@ -25,176 +27,8 @@ const MIN_DEPOSIT_AMOUNT = 1000; // 1,000원
const MAX_DEPOSIT_AMOUNT = 100_000_000; // 1억원 const MAX_DEPOSIT_AMOUNT = 100_000_000; // 1억원
const DEFAULT_HISTORY_LIMIT = 10; const DEFAULT_HISTORY_LIMIT = 10;
// D1 Session Management // Session manager instance
const DEPOSIT_SESSION_TTL = 30 * 60 * 1000; // 30분 (입금 작업은 빠르게 처리) const sessionManager = new SessionManager<DepositSession>(getSessionConfig('deposit'));
const MAX_MESSAGES = 10; // 세션당 최대 메시지 수
/**
* D1에서 입금 세션 조회
*
* @param db - D1 Database
* @param userId - Telegram User ID
* @returns DepositSession 또는 null (세션 없거나 만료)
*/
export async function getDepositSession(
db: D1Database,
userId: string
): Promise<DepositSession | null> {
try {
const now = Date.now();
const result = await db.prepare(
'SELECT * FROM deposit_sessions WHERE user_id = ? AND expires_at > ?'
).bind(userId, now).first<{
user_id: string;
status: string;
collected_info: string | null;
messages: string | null;
created_at: number;
updated_at: number;
expires_at: number;
}>();
if (!result) {
logger.info('입금 세션 없음', { userId });
return null;
}
const session: DepositSession = {
user_id: result.user_id,
status: result.status as DepositSessionStatus,
collected_info: result.collected_info ? JSON.parse(result.collected_info) : {},
messages: result.messages ? JSON.parse(result.messages) : [],
created_at: result.created_at,
updated_at: result.updated_at,
expires_at: result.expires_at,
};
logger.info('입금 세션 조회 성공', { userId, status: session.status });
return session;
} catch (error) {
logger.error('입금 세션 조회 실패', error as Error, { userId });
return null;
}
}
/**
* 입금 세션 저장 (생성 또는 업데이트)
*
* @param db - D1 Database
* @param session - DepositSession
*/
export async function saveDepositSession(
db: D1Database,
session: DepositSession
): Promise<void> {
try {
const now = Date.now();
const expiresAt = now + DEPOSIT_SESSION_TTL;
await db.prepare(`
INSERT INTO deposit_sessions
(user_id, status, collected_info, messages, created_at, updated_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
status = excluded.status,
collected_info = excluded.collected_info,
messages = excluded.messages,
updated_at = excluded.updated_at,
expires_at = excluded.expires_at
`).bind(
session.user_id,
session.status,
JSON.stringify(session.collected_info || {}),
JSON.stringify(session.messages || []),
session.created_at || now,
now,
expiresAt
).run();
logger.info('입금 세션 저장 성공', { userId: session.user_id, status: session.status });
} catch (error) {
logger.error('입금 세션 저장 실패', error as Error, { userId: session.user_id });
throw error;
}
}
/**
* 입금 세션 삭제
*
* @param db - D1 Database
* @param userId - Telegram User ID
*/
export async function deleteDepositSession(
db: D1Database,
userId: string
): Promise<void> {
try {
await db.prepare('DELETE FROM deposit_sessions WHERE user_id = ?')
.bind(userId)
.run();
logger.info('입금 세션 삭제 성공', { userId });
} catch (error) {
logger.error('입금 세션 삭제 실패', error as Error, { userId });
throw error;
}
}
/**
* 새 입금 세션 생성
*
* @param userId - Telegram User ID
* @param status - 세션 상태
* @returns 새로운 DepositSession 객체
*/
export function createDepositSession(
userId: string,
status: DepositSessionStatus = 'collecting_amount'
): DepositSession {
const now = Date.now();
return {
user_id: userId,
status,
collected_info: {},
messages: [],
created_at: now,
updated_at: now,
expires_at: now + DEPOSIT_SESSION_TTL,
};
}
/**
* 세션 만료 여부 확인
*
* @param session - DepositSession
* @returns true if expired, false otherwise
*/
export function isSessionExpired(session: DepositSession): boolean {
return session.expires_at < Date.now();
}
/**
* 세션에 메시지 추가
*
* @param session - DepositSession
* @param role - 메시지 역할 ('user' | 'assistant')
* @param content - 메시지 내용
*/
export function addMessageToSession(
session: DepositSession,
role: 'user' | 'assistant',
content: string
): void {
session.messages.push({ role, content });
// 최대 메시지 수 제한
if (session.messages.length > MAX_MESSAGES) {
session.messages = session.messages.slice(-MAX_MESSAGES);
logger.warn('세션 메시지 최대 개수 초과, 오래된 메시지 제거', {
userId: session.user_id,
maxMessages: MAX_MESSAGES,
});
}
}
/** /**
* 입금 세션 존재 여부 확인 (라우팅용) * 입금 세션 존재 여부 확인 (라우팅용)
@@ -204,8 +38,7 @@ export function addMessageToSession(
* @returns true if active session exists, false otherwise * @returns true if active session exists, false otherwise
*/ */
export async function hasDepositSession(db: D1Database, userId: string): Promise<boolean> { export async function hasDepositSession(db: D1Database, userId: string): Promise<boolean> {
const session = await getDepositSession(db, userId); return await sessionManager.has(db, userId);
return session !== null && !isSessionExpired(session);
} }
export interface DepositContext { export interface DepositContext {
@@ -286,28 +119,6 @@ const DEPOSIT_TOOLS = [
} }
]; ];
// OpenAI API response types
interface OpenAIToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string;
};
}
interface OpenAIMessage {
role: 'assistant';
content: string | null;
tool_calls?: OpenAIToolCall[];
}
interface OpenAIAPIResponse {
choices: Array<{
message: OpenAIMessage;
finish_reason: string;
}>;
}
/** /**
* Deposit Expert AI 호출 (Function Calling 지원) * Deposit Expert AI 호출 (Function Calling 지원)
@@ -523,15 +334,15 @@ export async function processDepositConsultation(
try { try {
// 1. Check for existing session // 1. Check for existing session
let session = await getDepositSession(db, userId); let session = await sessionManager.get(db, userId);
// 2. Create new session if none exists // 2. Create new session if none exists
if (!session) { if (!session) {
session = createDepositSession(userId, 'collecting_amount'); session = sessionManager.create(userId, 'collecting_amount');
} }
// 3. Add user message to session // 3. Add user message to session
addMessageToSession(session, 'user', userMessage); sessionManager.addMessage(session, 'user', userMessage);
// 4. Call AI to get response and possible tool calls // 4. Call AI to get response and possible tool calls
const aiResult = await callDepositExpertAI(session, userMessage, env); const aiResult = await callDepositExpertAI(session, userMessage, env);
@@ -589,15 +400,15 @@ export async function processDepositConsultation(
// 9. Handle __SESSION_END__ - session complete // 9. Handle __SESSION_END__ - session complete
if (finalResponse.includes('__SESSION_END__')) { if (finalResponse.includes('__SESSION_END__')) {
logger.info('입금 상담 세션 종료', { userId }); logger.info('입금 상담 세션 종료', { userId });
await deleteDepositSession(db, userId); await sessionManager.delete(db, userId);
finalResponse = finalResponse.replace('__SESSION_END__', '').trim(); finalResponse = finalResponse.replace('__SESSION_END__', '').trim();
return finalResponse; return finalResponse;
} }
// 10. Add assistant response to session and save // 10. Add assistant response to session and save
addMessageToSession(session, 'assistant', finalResponse); sessionManager.addMessage(session, 'assistant', finalResponse);
session.updated_at = Date.now(); session.updated_at = Date.now();
await saveDepositSession(db, session); await sessionManager.save(db, session);
logger.info('입금 상담 완료', { logger.info('입금 상담 완료', {
userId, userId,

View File

@@ -11,183 +11,13 @@
import type { Env, DomainSession, DomainSessionStatus } from '../types'; import type { Env, DomainSession, DomainSessionStatus } from '../types';
import { createLogger } from '../utils/logger'; import { createLogger } from '../utils/logger';
import { executeDomainAction, executeSuggestDomains } from '../tools/domain-tool'; import { executeDomainAction, executeSuggestDomains } from '../tools/domain-tool';
import { DomainSessionManager } from '../utils/session-manager';
import { getSessionConfig } from '../constants/agent-config';
const logger = createLogger('domain-agent'); const logger = createLogger('domain-agent');
// D1 Session Management // Session manager instance
const DOMAIN_SESSION_TTL = 60 * 60 * 1000; // 1시간 (도메인 작업은 시간이 더 필요) const sessionManager = new DomainSessionManager(getSessionConfig('domain'));
const MAX_MESSAGES = 20; // 세션당 최대 메시지 수
/**
* D1에서 도메인 세션 조회
*
* @param db - D1 Database
* @param userId - Telegram User ID
* @returns DomainSession 또는 null (세션 없거나 만료)
*/
export async function getDomainSession(
db: D1Database,
userId: string
): Promise<DomainSession | null> {
try {
const now = Date.now();
const result = await db.prepare(
'SELECT * FROM domain_sessions WHERE user_id = ? AND expires_at > ?'
).bind(userId, now).first<{
user_id: string;
status: string;
collected_info: string | null;
target_domain: string | null;
messages: string | null;
created_at: number;
updated_at: number;
expires_at: number;
}>();
if (!result) {
logger.info('도메인 세션 없음', { userId });
return null;
}
const session: DomainSession = {
user_id: result.user_id,
status: result.status as DomainSessionStatus,
collected_info: result.collected_info ? JSON.parse(result.collected_info) : {},
target_domain: result.target_domain || undefined,
messages: result.messages ? JSON.parse(result.messages) : [],
created_at: result.created_at,
updated_at: result.updated_at,
expires_at: result.expires_at,
};
logger.info('도메인 세션 조회 성공', { userId, status: session.status });
return session;
} catch (error) {
logger.error('도메인 세션 조회 실패', error as Error, { userId });
return null;
}
}
/**
* 도메인 세션 저장 (생성 또는 업데이트)
*
* @param db - D1 Database
* @param session - DomainSession
*/
export async function saveDomainSession(
db: D1Database,
session: DomainSession
): Promise<void> {
try {
const now = Date.now();
const expiresAt = now + DOMAIN_SESSION_TTL;
await db.prepare(`
INSERT INTO domain_sessions
(user_id, status, collected_info, target_domain, messages, created_at, updated_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
status = excluded.status,
collected_info = excluded.collected_info,
target_domain = excluded.target_domain,
messages = excluded.messages,
updated_at = excluded.updated_at,
expires_at = excluded.expires_at
`).bind(
session.user_id,
session.status,
JSON.stringify(session.collected_info || {}),
session.target_domain || null,
JSON.stringify(session.messages || []),
session.created_at || now,
now,
expiresAt
).run();
logger.info('도메인 세션 저장 성공', { userId: session.user_id, status: session.status });
} catch (error) {
logger.error('도메인 세션 저장 실패', error as Error, { userId: session.user_id });
throw error;
}
}
/**
* 도메인 세션 삭제
*
* @param db - D1 Database
* @param userId - Telegram User ID
*/
export async function deleteDomainSession(
db: D1Database,
userId: string
): Promise<void> {
try {
await db.prepare('DELETE FROM domain_sessions WHERE user_id = ?')
.bind(userId)
.run();
logger.info('도메인 세션 삭제 성공', { userId });
} catch (error) {
logger.error('도메인 세션 삭제 실패', error as Error, { userId });
throw error;
}
}
/**
* 새 도메인 세션 생성
*
* @param userId - Telegram User ID
* @param status - 세션 상태
* @returns 새로운 DomainSession 객체
*/
export function createDomainSession(
userId: string,
status: DomainSessionStatus = 'gathering'
): DomainSession {
const now = Date.now();
return {
user_id: userId,
status,
collected_info: {},
messages: [],
created_at: now,
updated_at: now,
expires_at: now + DOMAIN_SESSION_TTL,
};
}
/**
* 세션 만료 여부 확인
*
* @param session - DomainSession
* @returns true if expired, false otherwise
*/
export function isSessionExpired(session: DomainSession): boolean {
return session.expires_at < Date.now();
}
/**
* 세션에 메시지 추가
*
* @param session - DomainSession
* @param role - 메시지 역할 ('user' | 'assistant')
* @param content - 메시지 내용
*/
export function addMessageToSession(
session: DomainSession,
role: 'user' | 'assistant',
content: string
): void {
session.messages.push({ role, content });
// 최대 메시지 수 제한
if (session.messages.length > MAX_MESSAGES) {
session.messages = session.messages.slice(-MAX_MESSAGES);
logger.warn('세션 메시지 최대 개수 초과, 오래된 메시지 제거', {
userId: session.user_id,
maxMessages: MAX_MESSAGES,
});
}
}
// Domain Expert System Prompt // Domain Expert System Prompt
const DOMAIN_EXPERT_PROMPT = `당신은 10년 경력의 도메인 컨설턴트입니다. const DOMAIN_EXPERT_PROMPT = `당신은 10년 경력의 도메인 컨설턴트입니다.
@@ -306,28 +136,8 @@ const DOMAIN_TOOLS = [
} }
]; ];
// OpenAI API response types // Import OpenAI types from centralized types
interface OpenAIToolCall { import type { OpenAIToolCall, OpenAIMessage, OpenAIAPIResponse } from '../types';
id: string;
type: 'function';
function: {
name: string;
arguments: string;
};
}
interface OpenAIMessage {
role: 'assistant';
content: string | null;
tool_calls?: OpenAIToolCall[];
}
interface OpenAIAPIResponse {
choices: Array<{
message: OpenAIMessage;
finish_reason: string;
}>;
}
/** /**
* Domain Expert AI 호출 (Function Calling 지원) * Domain Expert AI 호출 (Function Calling 지원)
@@ -598,16 +408,16 @@ export async function processDomainConsultation(
try { try {
// 1. Check for existing session // 1. Check for existing session
let session = await getDomainSession(db, userId); let session = await sessionManager.get(db, userId);
// 2. Create new session if none exists and message seems domain-related // 2. Create new session if none exists and message seems domain-related
// (For first call, we always try to process - AI will return __PASSTHROUGH__ if not relevant) // (For first call, we always try to process - AI will return __PASSTHROUGH__ if not relevant)
if (!session) { if (!session) {
session = createDomainSession(userId, 'gathering'); session = sessionManager.create(userId, 'gathering');
} }
// 3. Add user message to session // 3. Add user message to session
addMessageToSession(session, 'user', userMessage); sessionManager.addMessage(session, 'user', userMessage);
// 4. Call AI to get response and possible tool calls // 4. Call AI to get response and possible tool calls
const aiResult = await callDomainExpertAI(session, userMessage, env); const aiResult = await callDomainExpertAI(session, userMessage, env);
@@ -648,15 +458,15 @@ export async function processDomainConsultation(
// 8. Handle __SESSION_END__ - session complete // 8. Handle __SESSION_END__ - session complete
if (finalResponse.includes('__SESSION_END__')) { if (finalResponse.includes('__SESSION_END__')) {
logger.info('도메인 상담 세션 종료', { userId }); logger.info('도메인 상담 세션 종료', { userId });
await deleteDomainSession(db, userId); await sessionManager.delete(db, userId);
finalResponse = finalResponse.replace('__SESSION_END__', '').trim(); finalResponse = finalResponse.replace('__SESSION_END__', '').trim();
return finalResponse; return finalResponse;
} }
// 9. Add assistant response to session and save // 9. Add assistant response to session and save
addMessageToSession(session, 'assistant', finalResponse); sessionManager.addMessage(session, 'assistant', finalResponse);
session.updated_at = Date.now(); session.updated_at = Date.now();
await saveDomainSession(db, session); await sessionManager.save(db, session);
logger.info('도메인 상담 완료', { logger.info('도메인 상담 완료', {
userId, userId,
@@ -680,6 +490,5 @@ export async function processDomainConsultation(
* @returns true if active session exists, false otherwise * @returns true if active session exists, false otherwise
*/ */
export async function hasDomainSession(db: D1Database, userId: string): Promise<boolean> { export async function hasDomainSession(db: D1Database, userId: string): Promise<boolean> {
const session = await getDomainSession(db, userId); return await sessionManager.has(db, userId);
return session !== null && !isSessionExpired(session);
} }

View File

@@ -15,74 +15,18 @@
* 4. Expected: Order confirmation * 4. Expected: Order confirmation
*/ */
import type { Env, ServerSession, ServerSessionStatus, BandwidthInfo, RecommendResponse } from '../types'; import type { Env, ServerSession, ServerSessionStatus, BandwidthInfo, RecommendResponse, OpenAIToolCall, OpenAIMessage, OpenAIAPIResponse } from '../types';
import { createLogger } from '../utils/logger'; import { createLogger } from '../utils/logger';
import { executeSearchWeb, executeLookupDocs } from '../tools/search-tool'; import { executeSearchWeb, executeLookupDocs } from '../tools/search-tool';
import { formatTrafficInfo } from '../utils/formatters'; import { formatTrafficInfo } from '../utils/formatters';
import { SERVER_CONSULTATION_STATUS, LANGUAGE_CODE } from '../constants'; import { SERVER_CONSULTATION_STATUS, LANGUAGE_CODE } from '../constants';
import { ServerSessionManager } from '../utils/session-manager';
import { getSessionConfig } from '../constants/agent-config';
const logger = createLogger('server-agent'); const logger = createLogger('server-agent');
// D1 Session Management // Session manager instance
const SERVER_SESSION_TTL_MS = 60 * 60 * 1000; // 1시간 const sessionManager = new ServerSessionManager(getSessionConfig('server'));
/**
* 새 서버 세션 생성
*
* @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,
});
}
}
/** /**
* 서버 세션 존재 여부 확인 (라우팅용) * 서버 세션 존재 여부 확인 (라우팅용)
@@ -92,122 +36,7 @@ export function addMessageToSession(
* @returns true if active session exists, false otherwise * @returns true if active session exists, false otherwise
*/ */
export async function hasServerSession(db: D1Database, userId: string): Promise<boolean> { export async function hasServerSession(db: D1Database, userId: string): Promise<boolean> {
const session = await getServerSession(db, userId); return await sessionManager.has(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
): Promise<ServerSession | null> {
try {
const now = Date.now();
const result = await db.prepare(
'SELECT * FROM server_sessions WHERE user_id = ? AND expires_at > ?'
).bind(userId, now).first<{
user_id: string;
status: string;
collected_info: string | null;
last_recommendation: string | null;
messages: string | null;
created_at: number;
updated_at: number;
expires_at: number;
}>();
if (!result) {
logger.info('세션 없음', { userId });
return null;
}
const session: ServerSession = {
user_id: result.user_id,
status: result.status as ServerSessionStatus,
collected_info: result.collected_info ? JSON.parse(result.collected_info) : {},
last_recommendation: result.last_recommendation ? JSON.parse(result.last_recommendation) : undefined,
messages: result.messages ? JSON.parse(result.messages) : [],
created_at: result.created_at,
updated_at: result.updated_at,
expires_at: result.expires_at,
};
logger.info('세션 조회 성공', { userId, status: session.status, hasLastRecommendation: !!session.last_recommendation });
return session;
} catch (error) {
logger.error('세션 조회 실패', error as Error, { userId });
return null;
}
}
/**
* 서버 세션 저장 (생성 또는 업데이트)
*
* @param db - D1 Database
* @param session - ServerSession
*/
export async function saveServerSession(
db: D1Database,
session: ServerSession
): Promise<void> {
try {
const now = Date.now();
const expiresAt = now + SERVER_SESSION_TTL_MS;
await db.prepare(`
INSERT INTO server_sessions
(user_id, status, collected_info, last_recommendation, messages, created_at, updated_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
status = excluded.status,
collected_info = excluded.collected_info,
last_recommendation = excluded.last_recommendation,
messages = excluded.messages,
updated_at = excluded.updated_at,
expires_at = excluded.expires_at
`).bind(
session.user_id,
session.status,
JSON.stringify(session.collected_info || {}),
session.last_recommendation ? JSON.stringify(session.last_recommendation) : null,
JSON.stringify(session.messages || []),
session.created_at || now,
now,
expiresAt
).run();
logger.info('세션 저장 성공', { userId: session.user_id, status: session.status });
} catch (error) {
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
): Promise<void> {
try {
await db.prepare('DELETE FROM server_sessions WHERE user_id = ?')
.bind(userId)
.run();
logger.info('세션 삭제 성공', { userId });
} catch (error) {
logger.error('세션 삭제 실패', error as Error, { userId });
throw error;
}
} }
/** /**
@@ -516,28 +345,6 @@ function inferExpectedUsers(scale: string, techStack?: string[]): number {
return 10; // Default to personal return 10; // Default to personal
} }
// OpenAI API 응답 타입
interface OpenAIToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string;
};
}
interface OpenAIMessage {
role: 'assistant';
content: string | null;
tool_calls?: OpenAIToolCall[];
}
interface OpenAIAPIResponse {
choices: Array<{
message: OpenAIMessage;
finish_reason: string;
}>;
}
/** /**
* Server Expert AI 호출 (Function Calling 지원) * Server Expert AI 호출 (Function Calling 지원)
@@ -778,11 +585,11 @@ export async function processServerConsultation(
try { try {
// 1. Check for existing session // 1. Check for existing session
let session = await getServerSession(db, userId); let session = await sessionManager.get(db, userId);
// 2. Create new session if none exists // 2. Create new session if none exists
if (!session) { if (!session) {
session = createServerSession(userId, 'gathering'); session = sessionManager.create(userId, 'gathering');
} }
// ordering 상태에서 "신청" 외 메시지 입력 시 세션 정리 // ordering 상태에서 "신청" 외 메시지 입력 시 세션 정리
@@ -790,7 +597,7 @@ export async function processServerConsultation(
// "신청"은 message-handler에서 처리, 여기까지 오면 다른 메시지임 // "신청"은 message-handler에서 처리, 여기까지 오면 다른 메시지임
const orderConfirmKey = `server_order_confirm:${session.user_id}`; const orderConfirmKey = `server_order_confirm:${session.user_id}`;
await env.SESSION_KV?.delete(orderConfirmKey); await env.SESSION_KV?.delete(orderConfirmKey);
await deleteServerSession(db, session.user_id); await sessionManager.delete(db, session.user_id);
logger.info('주문 확인 세션 취소 (다른 메시지 입력)', { userId: session.user_id }); logger.info('주문 확인 세션 취소 (다른 메시지 입력)', { userId: session.user_id });
return '__PASSTHROUGH__'; // 일반 대화로 전환 return '__PASSTHROUGH__'; // 일반 대화로 전환
@@ -800,7 +607,7 @@ export async function processServerConsultation(
// "취소", "다시", "처음", "리셋", "초기화" 등 // "취소", "다시", "처음", "리셋", "초기화" 등
if (/^(취소|다시|처음|리셋|초기화)/.test(userMessage.trim()) || if (/^(취소|다시|처음|리셋|초기화)/.test(userMessage.trim()) ||
/취소할[게래]|다시\s*시작|처음부터/.test(userMessage)) { /취소할[게래]|다시\s*시작|처음부터/.test(userMessage)) {
await deleteServerSession(db, session.user_id); await sessionManager.delete(db, session.user_id);
logger.info('사용자 요청으로 상담 취소', { logger.info('사용자 요청으로 상담 취소', {
userId: session.user_id, userId: session.user_id,
previousStatus: session.status, previousStatus: session.status,
@@ -811,14 +618,14 @@ export async function processServerConsultation(
// "서버 추천" 키워드로 새로 시작 요청 (기존 세션 리셋) // "서버 추천" 키워드로 새로 시작 요청 (기존 세션 리셋)
if (/서버\s*추천/.test(userMessage)) { if (/서버\s*추천/.test(userMessage)) {
await deleteServerSession(db, session.user_id); await sessionManager.delete(db, session.user_id);
logger.info('서버 추천 키워드로 세션 리셋', { logger.info('서버 추천 키워드로 세션 리셋', {
userId: session.user_id, userId: session.user_id,
previousStatus: session.status previousStatus: session.status
}); });
// 새 세션 생성하고 시작 메시지 반환 // 새 세션 생성하고 시작 메시지 반환
const newSession = createServerSession(session.user_id, 'gathering'); const newSession = sessionManager.create(session.user_id, 'gathering');
await saveServerSession(db, newSession); await sessionManager.save(db, newSession);
return '안녕하세요! 서버 추천을 도와드리겠습니다. 😊\n\n어떤 서비스를 운영하실 건가요?\n\n1. 웹 서비스 (SaaS, 랜딩페이지)\n2. 모바일 앱 백엔드\n3. AI/ML 서비스 (챗봇, 모델 서빙)\n4. 게임 서버\n5. Discord/Telegram 봇\n6. 자동화 서버 (n8n, 크롤링)\n7. 미디어 스트리밍\n8. 개발/테스트 환경\n9. 데이터베이스 서버\n10. 기타 (직접 입력)\n\n번호나 용도를 말씀해주세요!'; return '안녕하세요! 서버 추천을 도와드리겠습니다. 😊\n\n어떤 서비스를 운영하실 건가요?\n\n1. 웹 서비스 (SaaS, 랜딩페이지)\n2. 모바일 앱 백엔드\n3. AI/ML 서비스 (챗봇, 모델 서빙)\n4. 게임 서버\n5. Discord/Telegram 봇\n6. 자동화 서버 (n8n, 크롤링)\n7. 미디어 스트리밍\n8. 개발/테스트 환경\n9. 데이터베이스 서버\n10. 기타 (직접 입력)\n\n번호나 용도를 말씀해주세요!';
} }
@@ -836,7 +643,7 @@ export async function processServerConsultation(
// 명확히 다른 기능 요청인 경우 세션 종료하고 일반 처리로 전환 // 명확히 다른 기능 요청인 경우 세션 종료하고 일반 처리로 전환
const unrelatedPatterns = /기억|날씨|계산|검색|도메인|입금|충전|잔액|시간|문서/; const unrelatedPatterns = /기억|날씨|계산|검색|도메인|입금|충전|잔액|시간|문서/;
if (unrelatedPatterns.test(userMessage)) { if (unrelatedPatterns.test(userMessage)) {
await deleteServerSession(db, session.user_id); await sessionManager.delete(db, session.user_id);
logger.info('무관한 요청으로 세션 자동 종료', { logger.info('무관한 요청으로 세션 자동 종료', {
userId: session.user_id, userId: session.user_id,
message: userMessage.slice(0, 30) message: userMessage.slice(0, 30)
@@ -867,7 +674,7 @@ export async function processServerConsultation(
// Mark session as ordering // Mark session as ordering
session.status = 'ordering'; session.status = 'ordering';
await saveServerSession(db, session); await sessionManager.save(db, session);
// 주문 확인 세션 저장 (텍스트 기반 확인) // 주문 확인 세션 저장 (텍스트 기반 확인)
const orderConfirmKey = `server_order_confirm:${session.user_id}`; const orderConfirmKey = `server_order_confirm:${session.user_id}`;
@@ -942,7 +749,7 @@ export async function processServerConsultation(
// Mark session as recommending // Mark session as recommending
session.status = SERVER_CONSULTATION_STATUS.RECOMMENDING; session.status = SERVER_CONSULTATION_STATUS.RECOMMENDING;
await saveServerSession(db, session); await sessionManager.save(db, session);
// 1. Call recommendation API (추천 먼저 받기) // 1. Call recommendation API (추천 먼저 받기)
logger.info('추천 API 호출', { collectedInfo: session.collected_info }); logger.info('추천 API 호출', { collectedInfo: session.collected_info });
@@ -1064,7 +871,7 @@ export async function processServerConsultation(
// Mark session as selecting (사용자 선택 대기) // Mark session as selecting (사용자 선택 대기)
session.status = SERVER_CONSULTATION_STATUS.SELECTING; session.status = SERVER_CONSULTATION_STATUS.SELECTING;
await saveServerSession(db, session); await sessionManager.save(db, session);
// 4. 추천 결과 + AI 검토 코멘트 (검토 코멘트는 마지막에) // 4. 추천 결과 + AI 검토 코멘트 (검토 코멘트는 마지막에)
// __DIRECT__ 마커가 앞에 와야 제대로 처리됨 // __DIRECT__ 마커가 앞에 와야 제대로 처리됨
@@ -1072,14 +879,14 @@ export async function processServerConsultation(
} else { } else {
// 추천 결과 없음 - 세션 삭제 // 추천 결과 없음 - 세션 삭제
session.status = SERVER_CONSULTATION_STATUS.COMPLETED; session.status = SERVER_CONSULTATION_STATUS.COMPLETED;
await deleteServerSession(db, session.user_id); await sessionManager.delete(db, session.user_id);
return `${aiResult.message}\n\n조건에 맞는 서버를 찾지 못했습니다.`; return `${aiResult.message}\n\n조건에 맞는 서버를 찾지 못했습니다.`;
} }
} else { } else {
// Continue gathering information // Continue gathering information
session.status = SERVER_CONSULTATION_STATUS.GATHERING; session.status = SERVER_CONSULTATION_STATUS.GATHERING;
await saveServerSession(db, session); await sessionManager.save(db, session);
return aiResult.message; return aiResult.message;
} }
@@ -1088,7 +895,7 @@ export async function processServerConsultation(
// Clean up session on error (if exists) // Clean up session on error (if exists)
try { try {
await deleteServerSession(db, userId); await sessionManager.delete(db, userId);
} catch (deleteError) { } catch (deleteError) {
logger.error('세션 삭제 실패 (무시)', deleteError as Error, { userId }); logger.error('세션 삭제 실패 (무시)', deleteError as Error, { userId });
} }

View File

@@ -14,182 +14,16 @@
* 4. Expected: Session deleted * 4. Expected: Session deleted
*/ */
import type { Env, TroubleshootSession, TroubleshootSessionStatus } from '../types'; import type { Env, TroubleshootSession, TroubleshootSessionStatus, OpenAIToolCall, OpenAIMessage, OpenAIAPIResponse } from '../types';
import { createLogger } from '../utils/logger'; import { createLogger } from '../utils/logger';
import { executeSearchWeb, executeLookupDocs } from '../tools/search-tool'; import { executeSearchWeb, executeLookupDocs } from '../tools/search-tool';
import { SessionManager } from '../utils/session-manager';
import { getSessionConfig } from '../constants/agent-config';
const logger = createLogger('troubleshoot-agent'); const logger = createLogger('troubleshoot-agent');
// D1 Session Management // Session manager instance
const TROUBLESHOOT_SESSION_TTL_MS = 60 * 60 * 1000; // 1시간 const sessionManager = new SessionManager<TroubleshootSession>(getSessionConfig('troubleshoot'));
const MAX_MESSAGES = 20; // 세션당 최대 메시지 수
/**
* D1에서 트러블슈팅 세션 조회
*
* @param db - D1 Database
* @param userId - Telegram User ID
* @returns TroubleshootSession 또는 null (세션 없거나 만료)
*/
export async function getTroubleshootSession(
db: D1Database,
userId: string
): Promise<TroubleshootSession | null> {
try {
const now = Date.now();
const result = await db.prepare(
'SELECT * FROM troubleshoot_sessions WHERE user_id = ? AND expires_at > ?'
).bind(userId, now).first<{
user_id: string;
status: string;
collected_info: string | null;
messages: string | null;
created_at: number;
updated_at: number;
expires_at: number;
}>();
if (!result) {
logger.info('트러블슈팅 세션 없음', { userId });
return null;
}
const session: TroubleshootSession = {
user_id: result.user_id,
status: result.status as TroubleshootSessionStatus,
collected_info: result.collected_info ? JSON.parse(result.collected_info) : {},
messages: result.messages ? JSON.parse(result.messages) : [],
created_at: result.created_at,
updated_at: result.updated_at,
expires_at: result.expires_at,
};
logger.info('트러블슈팅 세션 조회 성공', { userId, status: session.status });
return session;
} catch (error) {
logger.error('트러블슈팅 세션 조회 실패', error as Error, { userId });
return null;
}
}
/**
* 트러블슈팅 세션 저장 (생성 또는 업데이트)
*
* @param db - D1 Database
* @param session - TroubleshootSession
*/
export async function saveTroubleshootSession(
db: D1Database,
session: TroubleshootSession
): Promise<void> {
try {
const now = Date.now();
const expiresAt = now + TROUBLESHOOT_SESSION_TTL_MS;
await db.prepare(`
INSERT INTO troubleshoot_sessions
(user_id, status, collected_info, messages, created_at, updated_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
status = excluded.status,
collected_info = excluded.collected_info,
messages = excluded.messages,
updated_at = excluded.updated_at,
expires_at = excluded.expires_at
`).bind(
session.user_id,
session.status,
JSON.stringify(session.collected_info || {}),
JSON.stringify(session.messages || []),
session.created_at || now,
now,
expiresAt
).run();
logger.info('트러블슈팅 세션 저장 성공', { userId: session.user_id, status: session.status });
} catch (error) {
logger.error('트러블슈팅 세션 저장 실패', error as Error, { userId: session.user_id });
throw error;
}
}
/**
* 트러블슈팅 세션 삭제
*
* @param db - D1 Database
* @param userId - Telegram User ID
*/
export async function deleteTroubleshootSession(
db: D1Database,
userId: string
): Promise<void> {
try {
await db.prepare('DELETE FROM troubleshoot_sessions WHERE user_id = ?')
.bind(userId)
.run();
logger.info('트러블슈팅 세션 삭제 성공', { userId });
} catch (error) {
logger.error('트러블슈팅 세션 삭제 실패', error as Error, { userId });
throw error;
}
}
/**
* 새 트러블슈팅 세션 생성
*
* @param userId - Telegram User ID
* @param status - 세션 상태
* @returns 새로운 TroubleshootSession 객체
*/
export function createTroubleshootSession(
userId: string,
status: TroubleshootSessionStatus = 'gathering'
): TroubleshootSession {
const now = Date.now();
return {
user_id: userId,
status,
collected_info: {},
messages: [],
created_at: now,
updated_at: now,
expires_at: now + TROUBLESHOOT_SESSION_TTL_MS,
};
}
/**
* 세션 만료 여부 확인
*
* @param session - TroubleshootSession
* @returns true if expired, false otherwise
*/
export function isSessionExpired(session: TroubleshootSession): boolean {
return session.expires_at < Date.now();
}
/**
* 세션에 메시지 추가
*
* @param session - TroubleshootSession
* @param role - 메시지 역할 ('user' | 'assistant')
* @param content - 메시지 내용
*/
export function addMessageToSession(
session: TroubleshootSession,
role: 'user' | 'assistant',
content: string
): void {
session.messages.push({ role, content });
// 최대 메시지 수 제한
if (session.messages.length > MAX_MESSAGES) {
session.messages = session.messages.slice(-MAX_MESSAGES);
logger.warn('세션 메시지 최대 개수 초과, 오래된 메시지 제거', {
userId: session.user_id,
maxMessages: MAX_MESSAGES,
});
}
}
/** /**
* 트러블슈팅 세션 존재 여부 확인 (라우팅용) * 트러블슈팅 세션 존재 여부 확인 (라우팅용)
@@ -199,8 +33,7 @@ export function addMessageToSession(
* @returns true if active session exists, false otherwise * @returns true if active session exists, false otherwise
*/ */
export async function hasTroubleshootSession(db: D1Database, userId: string): Promise<boolean> { export async function hasTroubleshootSession(db: D1Database, userId: string): Promise<boolean> {
const session = await getTroubleshootSession(db, userId); return await sessionManager.has(db, userId);
return session !== null && !isSessionExpired(session);
} }
// Troubleshoot Expert System Prompt // Troubleshoot Expert System Prompt
@@ -325,28 +158,6 @@ async function executeTroubleshootTool(
} }
} }
// OpenAI API 응답 타입
interface OpenAIToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string;
};
}
interface OpenAIMessage {
role: 'assistant';
content: string | null;
tool_calls?: OpenAIToolCall[];
}
interface OpenAIAPIResponse {
choices: Array<{
message: OpenAIMessage;
finish_reason: string;
}>;
}
/** /**
* Troubleshoot Expert AI 호출 (Function Calling 지원) * Troubleshoot Expert AI 호출 (Function Calling 지원)
@@ -503,15 +314,15 @@ export async function processTroubleshootConsultation(
try { try {
// 1. Check for existing session // 1. Check for existing session
let session = await getTroubleshootSession(db, userId); let session = await sessionManager.get(db, userId);
// 2. Create new session if none exists // 2. Create new session if none exists
if (!session) { if (!session) {
session = createTroubleshootSession(userId, 'gathering'); session = sessionManager.create(userId, 'gathering');
} }
// 3. Add user message to session // 3. Add user message to session
addMessageToSession(session, 'user', userMessage); sessionManager.addMessage(session, 'user', userMessage);
// 4. Call AI to get response and possible tool calls // 4. Call AI to get response and possible tool calls
const aiResult = await callTroubleshootExpertAI(session, userMessage, env); const aiResult = await callTroubleshootExpertAI(session, userMessage, env);
@@ -526,12 +337,12 @@ export async function processTroubleshootConsultation(
// 6. Handle __SESSION_END__ - session complete // 6. Handle __SESSION_END__ - session complete
if (aiResult.response.includes('[세션 종료]')) { if (aiResult.response.includes('[세션 종료]')) {
logger.info('트러블슈팅 상담 세션 종료', { userId }); logger.info('트러블슈팅 상담 세션 종료', { userId });
await deleteTroubleshootSession(db, userId); await sessionManager.delete(db, userId);
return aiResult.response.replace('[세션 종료]', '').trim(); return aiResult.response.replace('[세션 종료]', '').trim();
} }
// 7. Add assistant response to session and save // 7. Add assistant response to session and save
addMessageToSession(session, 'assistant', aiResult.response); sessionManager.addMessage(session, 'assistant', aiResult.response);
// Update session status based on response content (simple heuristic) // Update session status based on response content (simple heuristic)
if (aiResult.response.includes('원인') || aiResult.response.includes('분석')) { if (aiResult.response.includes('원인') || aiResult.response.includes('분석')) {
@@ -541,7 +352,7 @@ export async function processTroubleshootConsultation(
} }
session.updated_at = Date.now(); session.updated_at = Date.now();
await saveTroubleshootSession(db, session); await sessionManager.save(db, session);
logger.info('트러블슈팅 상담 완료', { logger.info('트러블슈팅 상담 완료', {
userId, userId,