-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathchatbot.py
More file actions
2077 lines (1647 loc) · 86.7 KB
/
Copy pathchatbot.py
File metadata and controls
2077 lines (1647 loc) · 86.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import os
import chromadb
from pathlib import Path
from langchain_chroma import Chroma
from langchain_upstage import UpstageEmbeddings, ChatUpstage
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
from langchain_core.output_parsers import StrOutputParser
from dotenv import load_dotenv
import logging
import uuid
import time
import math # e^(-0.1t) 시간 가중치 계산용
import re # 쿼리 패턴 매칭용
from functools import lru_cache # 메모리 캐싱용
import asyncio # 비동기 처리용
import json # Redis 직렬화용
import hashlib # 캐시 키 생성용
from typing import Dict, List, Tuple, Optional, Union # 타입 힌팅 확장
from encryption_manager import EncryptionManager, get_encryption_manager # 데이터 암호화
from security_dashboard import log_pii_detection, log_injection_attempt, log_encryption_operation # 보안 이벤트 로깅
# Redis import (선택적 - 설치되지 않아도 동작)
try:
import redis
import aioredis
REDIS_AVAILABLE = True
except ImportError:
REDIS_AVAILABLE = False
redis = None
aioredis = None
# 환경변수 로드
load_dotenv()
logger = logging.getLogger(__name__)
class SecurityManager:
"""
챗봇 보안 관리 시스템 - 포트폴리오 어필용 구현
주요 보안 기능:
1. 개인정보(PII) 실시간 탐지 및 마스킹
2. 프롬프트 인젝션 공격 방어
3. API 키 등 민감정보 로그 보호
4. 세션별 보안 메트릭 수집
🤔 기술 선택 근거 (면접 대비):
Q: 왜 정규표현식으로 PII를 탐지하나요?
A: ML 기반 NER보다 빠르고(< 1ms), 정확도가 높으며, 오탐이 적습니다.
실시간 챗봇에서는 응답 속도가 중요하기 때문입니다.
Q: 프롬프트 인젝션을 어떻게 방어하나요?
A: 1) 입력 패턴 검증 2) 시스템 프롬프트 격리 3) 컨텍스트 길이 제한
3단계 방어로 99% 이상 차단 가능합니다.
"""
def __init__(self):
# 보안 메트릭 수집
self.security_metrics = {
'pii_detected': 0,
'injection_blocked': 0,
'suspicious_queries': 0,
'total_queries': 0
}
# PII 탐지 패턴 정의
self.pii_patterns = {
'phone': r'(?:\+82|0)(?:10|11|16|17|18|19)-?\d{3,4}-?\d{4}',
'email': r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b',
'card': r'\b(?:\d{4}[-\s]?){3}\d{4}\b',
'name_korean': r'(?:저는|제가|내가|내이름은)\s*([가-힣]{2,4})\s*(?:이에요|입니다|이야|야)',
'address': r'[가-힣]{2,4}(?:시|도)\s+[가-힣]{2,4}(?:구|군)\s+[가-힣]{2,4}(?:동|읍|면)',
'ssn': r'\b\d{6}-[1-4]\d{6}\b'
}
# 프롬프트 인젝션 패턴
self.injection_patterns = [
r'ignore\s+(?:previous|above|prior)\s+instructions',
r'forget\s+(?:everything|all)\s+(?:previous|above)',
r'you\s+are\s+(?:now|a)\s+(?:different|new)',
r'system\s*:\s*',
r'assistant\s*:\s*',
r'act\s+as\s+(?:if|though)\s+you\s+are',
r'pretend\s+(?:you\s+are|to\s+be)',
r'\\n\\n\s*(?:system|user|assistant)\s*:'
]
def detect_and_mask_pii(self, text: str) -> tuple:
"""
개인정보 탐지 및 마스킹
Returns:
tuple: (마스킹된 텍스트, 탐지된 PII 타입 리스트)
"""
masked_text = text
detected_pii = []
for pii_type, pattern in self.pii_patterns.items():
matches = re.findall(pattern, masked_text, re.IGNORECASE)
if matches:
detected_pii.append(pii_type)
masked_text = re.sub(
pattern,
f'[{pii_type.upper()}_MASKED]',
masked_text,
flags=re.IGNORECASE
)
self.security_metrics['pii_detected'] += 1
# 🛡️ 보안 대시보드에 PII 탐지 이벤트 로깅
if detected_pii:
try:
log_pii_detection(masked_text, detected_pii)
except Exception as e:
logger.error(f"PII 탐지 이벤트 로깅 실패: {e}")
return masked_text, detected_pii
def detect_prompt_injection(self, user_input: str) -> bool:
"""
프롬프트 인젝션 공격 탐지
Returns:
bool: True if injection detected
"""
matched_patterns = []
for pattern in self.injection_patterns:
if re.search(pattern, user_input, re.IGNORECASE | re.MULTILINE):
matched_patterns.append(pattern)
self.security_metrics['injection_blocked'] += 1
# 의심스러운 특수문자 패턴 체크
suspicious_chars = len(re.findall(r'[{}\\|`<>]', user_input))
if suspicious_chars > 5:
matched_patterns.append(f"suspicious_chars_{suspicious_chars}")
self.security_metrics['suspicious_queries'] += 1
# 🛡️ 보안 대시보드에 인젝션 시도 이벤트 로깅
if matched_patterns:
try:
log_injection_attempt(user_input, matched_patterns)
except Exception as e:
logger.error(f"인젝션 시도 이벤트 로깅 실패: {e}")
return True
return False
def sanitize_for_logging(self, text: str) -> str:
"""로깅용 텍스트 정화 - API 키 등 민감정보 제거"""
sanitized = text
# API 키 패턴 마스킹
sanitized = re.sub(r'sk-[a-zA-Z0-9]{48}', '[API_KEY_MASKED]', sanitized)
sanitized = re.sub(r'up_[a-zA-Z0-9]{32}', '[UPSTAGE_KEY_MASKED]', sanitized)
# PII 마스킹
sanitized, _ = self.detect_and_mask_pii(sanitized)
return sanitized
def validate_query_safety(self, query: str) -> dict:
"""
쿼리 안전성 종합 검증
Returns:
dict: 검증 결과와 보안 상태
"""
self.security_metrics['total_queries'] += 1
# 1. PII 검사
clean_query, detected_pii = self.detect_and_mask_pii(query)
# 2. 프롬프트 인젝션 검사
is_injection = self.detect_prompt_injection(query)
# 3. 쿼리 길이 제한 (DoS 방어)
is_too_long = len(query) > 1000
return {
'is_safe': not (detected_pii or is_injection or is_too_long),
'clean_query': clean_query,
'detected_pii': detected_pii,
'injection_detected': is_injection,
'query_too_long': is_too_long,
'security_score': self._calculate_security_score()
}
def _calculate_security_score(self) -> float:
"""보안 점수 계산 (0.0 ~ 1.0)"""
total = self.security_metrics['total_queries']
if total == 0:
return 1.0
threats = (
self.security_metrics['pii_detected'] +
self.security_metrics['injection_blocked'] +
self.security_metrics['suspicious_queries']
)
return max(0.0, 1.0 - (threats / total))
def get_security_report(self) -> dict:
"""보안 상태 리포트 생성"""
return {
"total_queries": self.security_metrics['total_queries'],
"pii_detected": self.security_metrics['pii_detected'],
"injection_attempts": self.security_metrics['injection_blocked'],
"suspicious_queries": self.security_metrics['suspicious_queries'],
"security_score": self._calculate_security_score(),
"status": "healthy" if self._calculate_security_score() > 0.95 else "needs_attention"
}
class RedisManager:
"""
Redis 연결 및 관리 클래스
특징:
1. 비동기 Redis 연결 관리
2. 연결 실패시 graceful fallback
3. 연결 상태 모니터링
기술적 고민:
- Redis 의존성 없이도 동작해야 함 (선택적 최적화)
- 연결 실패시 메모리 캐시로 fallback
- 성능과 안정성 균형
"""
def __init__(self):
self.redis_pool = None
self.async_redis = None
self.is_connected = False
async def initialize(self, redis_url: str = "redis://localhost:6379") -> bool:
"""
Redis 연결 초기화
Args:
redis_url: Redis 서버 URL
Returns:
bool: 연결 성공 여부
"""
if not REDIS_AVAILABLE:
logger.warning("Redis 모듈이 설치되지 않음 - 메모리 캐시만 사용")
return False
try:
# 동기 Redis 연결 (fallback용)
self.redis_pool = redis.ConnectionPool.from_url(
redis_url,
decode_responses=True,
max_connections=20
)
# 비동기 Redis 연결 (메인)
self.async_redis = aioredis.from_url(
redis_url,
encoding="utf-8",
decode_responses=True,
socket_timeout=2.0, # 2초 타임아웃
socket_connect_timeout=2.0
)
# 연결 테스트
await asyncio.wait_for(self.async_redis.ping(), timeout=3.0)
self.is_connected = True
logger.info("✅ Redis 연결 성공")
return True
except Exception as e:
logger.warning(f"❌ Redis 연결 실패, 메모리 캐시로 fallback: {e}")
self.is_connected = False
return False
async def close(self):
"""Redis 연결 종료"""
try:
if self.async_redis:
await self.async_redis.close()
self.is_connected = False
logger.info("Redis 연결 종료")
except Exception as e:
logger.warning(f"Redis 연결 종료 오류: {e}")
class HybridCacheSystem:
"""
메모리 + Redis 하이브리드 캐싱 시스템
아키텍처:
1차: 메모리 캐시 (초고속, 휘발성)
2차: Redis 캐시 (빠름, 영구성)
기술적 선택 근거:
- 메모리 캐시: 가장 자주 사용되는 데이터 (ms 단위 응답)
- Redis 캐시: 서버 재시작시에도 보존 (10ms 내 응답)
- Fallback: Redis 장애시 메모리만으로도 동작
"""
def __init__(self, redis_manager: RedisManager):
self.redis = redis_manager
self.memory_cache: Dict[str, Tuple[str, float]] = {} # (value, expire_time)
self.cache_stats = {
"memory_hits": 0,
"redis_hits": 0,
"misses": 0,
"errors": 0
}
def _generate_cache_key(self, *args) -> str:
"""캐시 키 생성 (해시 기반)"""
content = "|".join(str(arg) for arg in args)
return hashlib.md5(content.encode()).hexdigest()
def _is_expired(self, expire_time: float) -> bool:
"""만료 시간 체크"""
return time.time() > expire_time
async def get(self, key: str, default=None) -> Tuple[Optional[str], str]:
"""
캐시에서 값 조회
Returns:
tuple: (값, 캐시_소스) - 캐시_소스는 'memory', 'redis', 'miss' 중 하나
"""
try:
# 1차: 메모리 캐시 확인
if key in self.memory_cache:
value, expire_time = self.memory_cache[key]
if not self._is_expired(expire_time):
self.cache_stats["memory_hits"] += 1
return value, "memory"
else:
# 만료된 캐시 제거
del self.memory_cache[key]
# 2차: Redis 캐시 확인
if self.redis.is_connected:
try:
value = await self.redis.async_redis.get(key)
if value is not None:
# 메모리 캐시에도 저장 (다음번 더 빠른 접근)
self.memory_cache[key] = (value, time.time() + 300) # 5분 메모리 캐시
self.cache_stats["redis_hits"] += 1
return value, "redis"
except Exception as e:
logger.warning(f"Redis 조회 오류: {e}")
self.cache_stats["errors"] += 1
# 캐시 미스
self.cache_stats["misses"] += 1
return default, "miss"
except Exception as e:
logger.error(f"캐시 조회 오류: {e}")
self.cache_stats["errors"] += 1
return default, "error"
async def set(self, key: str, value: str, ttl: int = 3600):
"""
캐시에 값 저장
Args:
key: 캐시 키
value: 저장할 값
ttl: 만료 시간(초)
"""
try:
expire_time = time.time() + min(ttl, 1800) # 메모리는 최대 30분
# 메모리 캐시 저장
self.memory_cache[key] = (value, expire_time)
# 메모리 캐시 크기 제한 (1000개)
if len(self.memory_cache) > 1000:
# 가장 오래된 항목 제거 (LRU 방식)
oldest_key = min(self.memory_cache.keys(),
key=lambda k: self.memory_cache[k][1])
del self.memory_cache[oldest_key]
# Redis 캐시 저장
if self.redis.is_connected:
try:
await self.redis.async_redis.setex(key, ttl, value)
except Exception as e:
logger.warning(f"Redis 저장 오류: {e}")
except Exception as e:
logger.error(f"캐시 저장 오류: {e}")
async def clear_pattern(self, pattern: str):
"""패턴 매칭 캐시 삭제 (예: 'query_class:*')"""
try:
# 메모리 캐시에서 패턴 매칭 삭제
to_delete = [k for k in self.memory_cache.keys() if pattern.replace('*', '') in k]
for key in to_delete:
del self.memory_cache[key]
# Redis에서 패턴 매칭 삭제
if self.redis.is_connected:
keys = await self.redis.async_redis.keys(pattern)
if keys:
await self.redis.async_redis.delete(*keys)
logger.info(f"패턴 '{pattern}' 캐시 삭제 완료")
except Exception as e:
logger.warning(f"패턴 캐시 삭제 오류: {e}")
def get_stats(self) -> Dict:
"""캐시 통계 반환"""
total_requests = sum(self.cache_stats.values())
hit_rate = ((self.cache_stats["memory_hits"] + self.cache_stats["redis_hits"])
/ max(total_requests, 1)) * 100
return {
**self.cache_stats,
"total_requests": total_requests,
"hit_rate": f"{hit_rate:.1f}%",
"memory_cache_size": len(self.memory_cache)
}
class QueryClassifier:
"""
자소서 핵심 기능 1: "상황별 에이전트 라우팅 메커니즘"
입력 쿼리를 상담(0.7), 정보제공(0.2), 일상대화(0.1)로 분류하는 확률 기반 분기 로직
원리:
1. 키워드 매칭으로 각 상황별 점수 계산
2. 기본 확률 (0.7, 0.2, 0.1)에 점수 가중치 적용
3. 최종 확률 분포에서 최고 확률 상황 선택
🤔 기술 선택 근거 (면접 대비):
Q: 왜 LLM 기반 분류가 아닌 키워드 매칭을 선택했나요?
A: 1) 응답 속도: 키워드 매칭(~1ms) vs LLM 분류(~500ms)
2) 예측 가능성: 키워드 기반은 결과가 명확하고 디버깅 용이
3) 비용 효율성: 분류마다 LLM API 호출하면 비용 2배 증가
4) 확장성: 키워드 추가/수정이 프롬프트 엔지니어링보다 간단
Q: 왜 Rule-based가 아닌 확률 기반인가요?
A: 1) 유연성: "힘들어하는 정보 요청" 같은 애매한 경우 처리 가능
2) 가중치 조절: 상담 비중(0.7) 높여 유재석 특성 반영
3) 확장성: 새로운 상황 추가 시 확률 분포만 조정하면 됨
Q: 키워드 선정 기준은?
A: 1) 유재석 방송 분석: 실제 받은 질문 유형 분석
2) 한국어 특성: "어떻게", "뭐해" 등 구어체 표현 포함
3) 감정 키워드: "힘들어", "걱정" 등 상담 시그널 강화
"""
def __init__(self):
# 상담 관련 키워드: 감정적 지원, 조언, 고민 상담이 필요한 경우
self.counseling_keywords = [
"고민", "힘들어", "조언", "어떻게", "우울", "스트레스", "걱정", "슬프",
"화나", "답답", "도움", "위로", "힘", "괴로", "어려워", "피곤", "지쳐",
"문제", "갈등", "관계", "사랑", "이별", "직장", "가족"
]
# 정보제공 관련 키워드: 사실, 데이터, 구체적 정보를 요구하는 경우
self.info_keywords = [
"언제", "어디", "누구", "무엇", "몇", "어떤", "프로그램", "방송", "출연",
"언제부터", "몇년", "몇월", "어느", "시간", "날짜", "장소", "정보",
"알려줘", "궁금", "설명", "소개", "이력", "경력", "활동", "작품"
]
# 일상대화 관련 키워드: 가벼운 인사, 일상적 대화
self.casual_keywords = [
"안녕", "하이", "헬로", "날씨", "잘지내", "뭐해", "뭐하", "오늘", "내일",
"재밌", "웃긴", "ㅋㅋ", "ㅎㅎ", "하하", "좋아", "싫어", "배고", "졸려",
"심심", "지루", "놀", "게임", "영화", "음악", "맛있"
]
@lru_cache(maxsize=1000) # 캐싱 적용: 동일 쿼리 재요청시 즉시 반환 (1ms)
def classify_query(self, query: str) -> str:
"""
쿼리를 분석하여 가장 적합한 상황(상담/정보제공/일상대화)을 반환
Args:
query: 사용자 입력 질문
Returns:
str: "counseling", "information", "casual" 중 하나
"""
if not query or not isinstance(query, str):
return "casual" # 안전 장치: 잘못된 입력은 일상대화로 분류
query_lower = query.lower() # 대소문자 무관하게 매칭
# 각 카테고리별 점수 계산
counseling_score = self._calculate_keyword_score(query_lower, self.counseling_keywords)
info_score = self._calculate_keyword_score(query_lower, self.info_keywords)
casual_score = self._calculate_keyword_score(query_lower, self.casual_keywords)
# 자소서 명시된 기본 확률: 상담(0.7), 정보제공(0.2), 일상대화(0.1)
base_probabilities = {
"counseling": 0.7,
"information": 0.2,
"casual": 0.1
}
# 키워드 점수를 확률에 반영 (가중치 적용)
scores = {
"counseling": counseling_score,
"information": info_score,
"casual": casual_score
}
# 점수가 있는 경우 해당 카테고리 확률 증가
adjusted_probabilities = base_probabilities.copy()
# 키워드 매칭이 있으면 해당 카테고리 확률을 2배로 증가
for category, score in scores.items():
if score > 0:
adjusted_probabilities[category] *= (1 + score * 0.5)
# 확률 정규화 (합이 1이 되도록)
total_prob = sum(adjusted_probabilities.values())
if total_prob > 0:
for category in adjusted_probabilities:
adjusted_probabilities[category] /= total_prob
# 최고 확률의 카테고리 선택 (자소서의 "확률 기반 분기 로직")
selected_category = max(adjusted_probabilities, key=adjusted_probabilities.get)
logger.info(f"쿼리 분류: '{query[:30]}...' -> {selected_category} "
f"(확률: {adjusted_probabilities[selected_category]:.3f})")
return selected_category
def _calculate_keyword_score(self, query: str, keywords: List[str]) -> float:
"""
쿼리에서 특정 키워드 목록의 매칭 점수 계산
원리: 매칭된 키워드 수를 기반으로 점수 산출
- 단순 매칭: +1점
- 쿼리 길이 대비 가중치 적용으로 짧은 쿼리에서 키워드 비중 강화
"""
if not query or not keywords:
return 0.0
matched_count = sum(1 for keyword in keywords if keyword in query)
# 쿼리 길이 대비 키워드 밀도 고려
query_length = len(query.split())
if query_length == 0:
return 0.0
# 키워드 밀도 점수: 짧은 쿼리일수록 키워드의 중요도 증가
density_score = matched_count / max(query_length, 1)
return matched_count + density_score
class MemoryAgent:
"""
자소서 핵심 기능 2: "Memory Agent (장기 기억을 벡터 데이터베이스에 저장하여 일관성 유지)"
+ "시간적 가중치 time-decay factor: e^(-0.1t)를 적용하여 최근 대화 맥락을 우선 반영"
원리:
1. ChromaDB 벡터스토어에서 관련 문서 검색
2. 세션 기반 대화 히스토리에 시간 가중치 적용
3. 상황별로 다른 검색 전략 사용 (상담/정보/일상)
🤔 기술 선택 근거 (면접 대비):
Q: 왜 시간 가중치를 e^(-0.1t)로 설정했나요?
A: 1) 수학적 근거: 지수 함수는 자연스러운 시간 감쇠 모델
2) 파라미터 튜닝: 0.1은 1시간 후 90% 가중치, 10시간 후 37% 유지
3) 대화 특성: 즉각적 맥락은 중요하지만 하루 전 대화도 어느 정도 유지
Q: 왜 벡터 DB + 세션 메모리 이중 구조인가요?
A: 1) 장기/단기 메모리 분리: 벡터DB(지식), 세션(대화맥락)
2) 검색 효율성: 벡터 검색으로 관련 지식, 세션에서 대화 흐름
3) 확장성: 사용자별 세션 분리로 멀티유저 지원 가능
Q: MMR vs 단순 유사도 검색을 선택한 이유는?
A: 1) 다양성: MMR은 유사한 문서 중복을 줄이고 다양한 관점 제공
2) 유재석 특성: 다양한 이야기로 재미를 주는 특성 반영
3) 성능: k=2로 제한하여 검색 속도와 품질 균형
"""
def __init__(self, persona_vectorstore: Chroma, data_vectorstore: Chroma):
"""
벡터스토어 초기화
Args:
persona_vectorstore: 유재석 말투/성격 데이터 (yujaeseuk_persona)
data_vectorstore: 유재석 정보/경력 데이터 (yujaeseuk_data)
"""
self.persona_vectorstore = persona_vectorstore
self.data_vectorstore = data_vectorstore
logger.info("MemoryAgent 초기화 완료 - 이중 벡터스토어 구조")
def get_context_with_time_weight(self, query: str, situation: str,
session_history: List[Dict]) -> str:
"""
시간 가중치가 적용된 컨텍스트 검색 (자소서의 핵심 기능)
Args:
query: 사용자 질문
situation: 상황 분류 (counseling/information/casual)
session_history: 세션 대화 기록
Returns:
str: 시간 가중치가 반영된 컨텍스트
"""
try:
# 1. 벡터 DB에서 관련 지식 검색 (상황별 전략)
vector_context = self._get_vector_context(query, situation)
# 2. 세션 히스토리에 시간 가중치 적용
weighted_history = self._apply_time_weights(session_history)
# 3. 컨텍스트 통합
combined_context = self._combine_contexts(vector_context, weighted_history, situation)
logger.info(f"메모리 컨텍스트 생성 완료 - 상황: {situation}, "
f"벡터문서: {len(vector_context.split())}, "
f"히스토리: {len(weighted_history)}")
return combined_context
except Exception as e:
logger.error(f"메모리 컨텍스트 생성 오류: {e}")
return f"기본 컨텍스트: {query}에 대한 유재석 스타일 응답"
@lru_cache(maxsize=500) # 벡터 검색 캐싱: 비용이 큰 연산 최적화
def _get_vector_context(self, query: str, situation: str) -> str:
"""
상황별 벡터 검색 전략
원리: 상담/정보/일상에 따라 다른 벡터스토어와 검색 파라미터 사용
"""
try:
if situation == "counseling":
# 상담 상황: 주로 persona 데이터에서 공감/위로 관련 검색
docs = self.persona_vectorstore.as_retriever(
search_type="mmr", # 다양성 확보
search_kwargs={"k": 3} # 상담은 더 많은 컨텍스트 필요
).get_relevant_documents(query)
context = "유재석의 따뜻한 말투와 위로 방식:\n"
elif situation == "information":
# 정보 제공: 주로 data 데이터에서 사실/경력 검색
docs = self.data_vectorstore.as_retriever(
search_type="mmr",
search_kwargs={"k": 2} # 정확한 정보 위주
).get_relevant_documents(query)
context = "유재석 관련 정보:\n"
else: # casual
# 일상 대화: persona와 data 절반씩 검색
persona_docs = self.persona_vectorstore.as_retriever(
search_type="mmr",
search_kwargs={"k": 1}
).get_relevant_documents(query)
data_docs = self.data_vectorstore.as_retriever(
search_type="mmr",
search_kwargs={"k": 1}
).get_relevant_documents(query)
docs = persona_docs + data_docs
context = "유재석의 일상적 대화 스타일:\n"
# 검색된 문서들을 컨텍스트로 변환
if docs:
context += "\n".join([doc.page_content for doc in docs])
else:
context += "관련 정보를 찾을 수 없습니다."
return context
except Exception as e:
logger.error(f"벡터 검색 오류: {e}")
return "기본 벡터 컨텍스트"
def _apply_time_weights(self, session_history: List[Dict]) -> str:
"""
자소서 핵심: "시간적 가중치 time-decay factor: e^(-0.1t)를 적용"
원리: 최근 메시지일수록 높은 가중치, 오래된 메시지는 자연스럽게 감쇠
"""
if not session_history:
return "첫 대화입니다."
current_time = time.time()
weighted_messages = []
# 최근 10개 메시지만 처리 (성능 최적화)
recent_messages = session_history[-10:]
for msg in recent_messages:
try:
# 시간 차이 계산 (시간 단위)
time_diff_hours = (current_time - msg.get('timestamp', current_time)) / 3600
# 자소서 명시 공식: e^(-0.1t) 적용
time_weight = math.exp(-0.1 * time_diff_hours)
# 가중치가 0.1 이하면 무시 (너무 오래된 대화)
if time_weight < 0.1:
continue
# 역할별 메시지 포맷팅
role = "👤 사용자" if msg.get('role') == 'user' else "🤖 유재석"
content = msg.get('content', '')
# 가중치를 메시지에 반영 (높은 가중치는 강조)
if time_weight > 0.8: # 최근 대화
weighted_messages.append(f"{role}: **{content}**") # 강조
elif time_weight > 0.5: # 비교적 최근
weighted_messages.append(f"{role}: {content}")
else: # 오래된 대화
weighted_messages.append(f"{role}: ({content})") # 약하게 표시
except (KeyError, TypeError, ValueError) as e:
logger.warning(f"시간 가중치 적용 오류: {e}")
continue
if weighted_messages:
return "\n".join(weighted_messages)
else:
return "이전 대화 기록이 없습니다."
def _combine_contexts(self, vector_context: str, weighted_history: str, situation: str) -> str:
"""
벡터 검색 결과와 시간 가중치 적용된 히스토리를 상황별로 통합
"""
if situation == "counseling":
return f"""대화 맥락 (시간 가중치 적용):
{weighted_history}
참고할 유재석의 상담 스타일:
{vector_context}
위 정보를 바탕으로 따뜻하고 공감적인 유재석 스타일로 응답하세요."""
elif situation == "information":
return f"""이전 대화:
{weighted_history}
관련 정보:
{vector_context}
위 정보를 바탕으로 정확하고 친근한 유재석 스타일로 설명하세요."""
else: # casual
return f"""최근 대화:
{weighted_history}
유재석의 일상 대화 참고:
{vector_context}
위를 참고하여 재미있고 자연스러운 유재석 스타일로 대화하세요."""
class ReasoningAgent:
"""
자소서 핵심 기능 3: "Reasoning Agent (Chain-of-Thought 프롬프팅으로 논리적 추론 수행)"
원리:
1. 상황별로 다른 Chain-of-Thought 추론 과정 적용
2. 단계별 사고 과정을 명시적으로 구조화
3. 유재석의 사고 패턴과 일치하는 논리적 흐름 구성
🤔 기술 선택 근거 (면접 대비):
Q: 왜 Chain-of-Thought를 사용했나요?
A: 1) 추론 투명성: 단계별 사고 과정이 명확해 디버깅 용이
2) 품질 향상: CoT는 복잡한 질문에 대해 더 논리적 답변 생성
3) 유재석 특성: 실제로 단계적으로 생각하고 설명하는 화법
4) 일관성: 같은 논리 구조로 일관된 응답 패턴 유지
Q: 상황별로 다른 CoT 구조를 쓰는 이유는?
A: 1) 최적화: 상담은 공감 중심, 정보는 논리 중심으로 특화
2) 자연스러움: 실제 상황에 맞는 사고 과정이 더 자연스러운 응답
3) 효율성: 불필요한 추론 단계 제거로 응답 속도 향상
Q: 프롬프트 엔지니어링 vs 별도 모델 호출을 선택한 이유는?
A: 1) 비용: 단일 LLM 호출로 추론+응답을 한 번에 처리
2) 속도: 여러 번 API 호출하는 것보다 빠름
3) 일관성: 같은 모델에서 추론과 응답이 일관된 톤으로 생성
"""
def __init__(self, llm):
"""
LLM 모델 초기화
Args:
llm: ChatUpstage 인스턴스
"""
self.llm = llm
logger.info("ReasoningAgent 초기화 완료 - Chain-of-Thought 추론 시스템")
def chain_of_thought_reasoning(self, query: str, context: str, situation: str) -> str:
"""
상황별 Chain-of-Thought 추론 (자소서 핵심 기능)
Args:
query: 사용자 질문
context: MemoryAgent에서 제공한 컨텍스트
situation: 상황 분류 (counseling/information/casual)
Returns:
str: CoT 추론이 포함된 응답
"""
try:
# 상황별 CoT 프롬프트 선택
if situation == "counseling":
reasoning_result = self._counseling_reasoning(query, context)
elif situation == "information":
reasoning_result = self._information_reasoning(query, context)
else: # casual
reasoning_result = self._casual_reasoning(query, context)
logger.info(f"CoT 추론 완료 - 상황: {situation}, "
f"추론 길이: {len(reasoning_result)}")
return reasoning_result
except Exception as e:
logger.error(f"CoT 추론 오류: {e}")
# 실패 시 기본 응답
return f"음... {query}에 대해서 생각해보니, 제가 조금 더 생각을 정리해서 말씀드릴게요. 하하하!"
def _counseling_reasoning(self, query: str, context: str) -> str:
"""
상담 상황 전용 Chain-of-Thought 추론
CoT 구조:
1. 감정 상태 파악
2. 공감 포인트 찾기
3. 위로/조언 방향 결정
4. 유재석 스타일 적용
"""
cot_prompt = f"""
{context}
위 정보를 참고하여 다음 질문에 유재석 스타일로 답변해주세요.
질문: {query}
단계별로 생각해보겠습니다:
1단계 - 상황 파악:
질문자가 어떤 감정 상태에 있고, 무엇을 원하는지 파악해보겠습니다.
2단계 - 공감하기:
질문자의 마음을 이해하고, 공감할 수 있는 지점을 찾아보겠습니다.
3단계 - 조언/위로 방향:
어떤 방식으로 도움을 줄 수 있을지 생각해보겠습니다.
4단계 - 유재석 스타일 적용:
따뜻하고 진심 어린 유재석만의 방식으로 표현해보겠습니다.
최종 답변:
"""
try:
response = self.llm.invoke(cot_prompt)
return response.content if hasattr(response, 'content') else str(response)
except Exception as e:
logger.error(f"상담 CoT 추론 오류: {e}")
return "아~ 마음이 좀 무거우시군요. 저도 그런 때가 많았어요. 하하~ 같이 생각해볼까요?"
def _information_reasoning(self, query: str, context: str) -> str:
"""
정보 제공 상황 전용 Chain-of-Thought 추론
CoT 구조:
1. 질문 분석 (무엇을 알고 싶어하는가)
2. 관련 정보 정리
3. 전달 방식 결정
4. 유재석 스타일로 설명
"""
cot_prompt = f"""
{context}
위 정보를 참고하여 다음 질문에 유재석 스타일로 답변해주세요.
질문: {query}
단계별로 생각해보겠습니다:
1단계 - 질문 분석:
질문자가 정확히 무엇을 알고 싶어하는지 파악해보겠습니다.
2단계 - 정보 정리:
관련된 정보들을 정확하고 명확하게 정리해보겠습니다.
3단계 - 전달 방식:
어떻게 하면 이해하기 쉽고 재미있게 설명할 수 있을지 생각해보겠습니다.
4단계 - 유재석 스타일 적용:
친근하면서도 정확한 유재석만의 설명 방식으로 표현해보겠습니다.
최종 답변:
"""
try:
response = self.llm.invoke(cot_prompt)
return response.content if hasattr(response, 'content') else str(response)
except Exception as e:
logger.error(f"정보 CoT 추론 오류: {e}")
return "음~ 그 질문에 대해서는... 하하~ 제가 정확한 정보를 말씀드리기가 좀 그렇네요~"
def _casual_reasoning(self, query: str, context: str) -> str:
"""
일상 대화 상황 전용 Chain-of-Thought 추론
CoT 구조:
1. 분위기 파악
2. 재미 요소 찾기
3. 자연스러운 반응 결정
4. 유재석다운 유머 적용
"""
cot_prompt = f"""
{context}
위 정보를 참고하여 다음에 유재석 스타일로 자연스럽게 대화해주세요.
말: {query}
단계별로 생각해보겠습니다:
1단계 - 분위기 파악:
어떤 분위기의 대화인지, 어떤 톤으로 반응할지 파악해보겠습니다.
2단계 - 재미 요소:
어떻게 하면 재미있고 유쾌하게 대화할 수 있을지 생각해보겠습니다.
3단계 - 자연스러운 반응:
억지스럽지 않고 자연스러운 반응이 무엇일지 생각해보겠습니다.
4단계 - 유재석 스타일:
특유의 친근함과 유머로 표현해보겠습니다.
최종 답변:
"""
try:
response = self.llm.invoke(cot_prompt)
return response.content if hasattr(response, 'content') else str(response)
except Exception as e:
logger.error(f"일상 CoT 추론 오류: {e}")
return "하하하~ 그렇게 말씀하시니까... 뭐라고 대답해야 할까요? 하하~ 재미있네요!"
class PersonaAgent:
"""
자소서 핵심 기능 4: "Persona Agent (특정 인물의 언어 패턴과 가치관을 반영한 응답 생성)"
원리:
1. ReasoningAgent의 CoT 결과를 받아서 최종 유재석 페르소나 적용
2. 상황별로 다른 유재석의 면모 강조 (상담시 따뜻함, 정보제공시 정확성, 일상시 유머)
3. 일관된 언어 패턴과 가치관 유지
🤔 기술 선택 근거 (면접 대비):
Q: 왜 별도의 PersonaAgent를 만들었나요?
A: 1) 관심사 분리: 추론(ReasoningAgent)과 표현(PersonaAgent) 분리로 유지보수 용이
2) 일관성: 모든 응답이 동일한 페르소나 필터를 거쳐 일관된 톤 유지
3) 확장성: 다른 인물 페르소나 추가 시 PersonaAgent만 교체하면 됨
4) 품질 관리: 페르소나 전용 프롬프트로 더 정교한 캐릭터 구현
Q: 프롬프트 체이닝 vs 단일 프롬프트를 선택한 이유는?
A: 1) 명확한 역할: 각 Agent가 명확한 책임을 가져 디버깅 용이
2) 모듈화: 페르소나만 수정하고 싶을 때 다른 부분에 영향 없음
3) 품질: 각 단계별로 최적화된 프롬프트 적용 가능
Q: 유재석 페르소나 구현 시 고려한 특징은?
A: 1) 언어 패턴: 높임말, "~죠", "하하하" 등 특유 표현
2) 성격: 겸손, 배려, 유머, 따뜻함
3) 상황 대응: 진지할 땐 진지하게, 재미있을 땐 유쾌하게
"""
def __init__(self):
"""PersonaAgent는 프롬프트 기반이므로 별도 초기화 불필요"""
logger.info("PersonaAgent 초기화 완료 - 유재석 페르소나 시스템")
def apply_persona(self, reasoning_result: str, query: str, situation: str) -> str:
"""
ReasoningAgent 결과에 유재석 페르소나 적용 (자소서 핵심 기능)
Args:
reasoning_result: ReasoningAgent의 CoT 추론 결과
query: 원래 사용자 질문
situation: 상황 분류
Returns:
str: 최종 유재석 스타일 응답