""" πŸ”± Imperial Emotion Utility β€” 단일 μ§„μ‹€ μ €μž₯μ†Œ 감정 ν‚€μ›Œλ“œ λͺ©λ‘, νƒœκ·Έ μΆ”μΆœ, νƒœκ·Έ 제거 ν•¨μˆ˜λ₯Ό μ—¬κΈ°μ„œλ§Œ μ •μ˜ν•©λ‹ˆλ‹€. chat_routes.py / web_server.py / discord_service.py λͺ¨λ‘ 이 λͺ¨λ“ˆμ„ μž„ν¬νŠΈν•˜μ‹­μ‹œμ˜€. """ import re import time import logging _emotion_logger = logging.getLogger(__name__) EMOTION_KEYWORDS = [ 'happy', 'sad', 'angry', 'surprised', 'disgust', 'smirk', 'neutral', 'peaceful', 'excited', 'serious', 'thinking', 'love', 'shy', 'fear', 'fearful', 'joy', 'joyful', 'pain', 'tired', 'bored', 'playful', 'confident', 'anxious', 'relief', 'devoted', 'loyal', 'proud', 'sympathetic', ] # [EMOTION:xxx] / [EMOTION Board:xxx] EMOTION_BOARD_RE = re.compile(r'\[EMOTION(?:\s+Board)?:([^\]]*)\]', re.IGNORECASE) # λ ˆκ±°μ‹œ 인라인 νƒœκ·Έ [happy] λ“± INLINE_EMOTION_RE = re.compile( r'\[(' + '|'.join(EMOTION_KEYWORDS) + r')\]', re.IGNORECASE, ) # ν”„λ‘¬ν”„νŠΈμš© ν—ˆμš© 감정 λͺ©λ‘ λ¬Έμžμ—΄ ALLOWED_EMOTION_LIST = ', '.join(EMOTION_KEYWORDS) # 🎭 가독성·감정 μ§€μΉ¨μ˜ 'ν•˜λ“œμ½”λ”© 폴백'은 제거됨(2026-06-29). # κ³Όκ±° 브레인이 Aegisλ₯Ό λͺ» 읽으면(토큰 λˆ„λ½ 401 λ“±) 쑰용히 이 ν•˜λ“œμ½”λ”© 값을 μ£Όμž…ν•΄, # '곡톡 μ§€μΉ¨ κ΄€λ¦¬μž' νŽΈμ§‘μ΄ μ•ˆ λ¨ΉνžˆλŠ” footgunμ΄μ—ˆλ‹€. 이제 μ§€μΉ¨ ν…μŠ€νŠΈμ˜ 'μœ μΌν•œ μ§„μ‹€'은 # Aegis DB(곡톡 μ§€μΉ¨ κ΄€λ¦¬μž)이며, 쑰회 μ‹€νŒ¨ μ‹œ 빈 κ°’(μ£Όμž… μ•ˆ 함)으둜 μ‹€νŒ¨λ₯Ό λ“œλŸ¬λ‚Έλ‹€. # (DB μ‹œλ“œ 기본값은 Aegis system_views.DEFAULT_*κ°€ λ‹΄λ‹Ή β€” UI 초기 μ½˜ν…μΈ .) def _append_emotion_example(instruction: str, example: str | None) -> str: """감정 μ§€μΉ¨ λ³Έλ¬Έ 끝에 말투 μ˜ˆμ‹œ ν•œ 쀄을 덧뢙인닀(μ˜ˆμ‹œκ°€ λΉ„λ©΄ κ·ΈλŒ€λ‘œ). DB의 emotion_example(ν† κΈ€ ON)을 본문에 뢙일 λ•Œ μ‚¬μš©.""" ex = (example or "").strip() if not ex: return instruction return instruction.rstrip() + f"\n (예: \"{ex}\")" # ───────────────────────────────────────────────────────────────────────────── # πŸ”± 동적 μ§€μΉ¨ λ‘œλ” (Aegis REST API β†’ λ©”λͺ¨λ¦¬ μΊμ‹œ β†’ ν•˜λ“œμ½”λ”© 폴백) # νŽΈμ§‘ μ¦‰μ‹œμ„±μ€ 'λ¬΄νš¨ν™” ν›…'이 λ‹΄λ‹Ήν•œλ‹€: Aegisκ°€ 곡톡 μ§€μΉ¨ μ €μž₯ 직후 # POST /api/shadow/guidelines/invalidate 둜 μΊμ‹œλ₯Ό λΉ„μ›Œ, νŽΈμ§‘μ΄ 'λ‹€μŒ λ©”μ‹œμ§€ 1번'에 λ°”λ‘œ # λ°˜μ˜λœλ‹€(TTL 만료λ₯Ό 기닀릴 ν•„μš” μ—†μŒ β€” '두 번 말 κ±ΈκΈ°' 뢈편 ν•΄μ†Œ). # λ”°λΌμ„œ TTL은 'λŒ€ν™” 쀑 쀑볡 호좜 절감 + 훅이 λ‹Ώμ§€ μ•ŠλŠ” 경우(브레인 μž¬κΈ°λ™/ν΄λΌμš°λ“œ NAT)의 # μ•ˆμ „λ§' μ—­ν• λ§Œ ν•œλ‹€. DB(cache_ttl_seconds, κ΄€λ¦¬μž νŽΈμ§‘)κ°€ μš°μ„ μ΄λ©° κΈ°λ³Έ 60초. # μ™„μ „ λ¬΄μΊμ‹œλŠ” λ§€ λ©”μ‹œμ§€λ§ˆλ‹€ 3μ’…(가독성/감정/검색정책)을 Aegis둜 GETν•΄ λΉ„νš¨μœ¨μ μ΄λ‹€. # Aegis 미기동 μ‹œμ—λ„ ν•˜λ“œμ½”λ”© 폴백으둜 정상 μž‘λ™ν•œλ‹€. # ───────────────────────────────────────────────────────────────────────────── _GUIDELINES_CACHE: dict = {} _GUIDELINES_TTL: float = 60.0 # κΈ°λ³Έ 60초(μ•ˆμ „λ§). μ¦‰μ‹œμ„±μ€ λ¬΄νš¨ν™” 훅이 λ‹΄λ‹Ή. DB값이 μš°μ„ . # ⏱️ DB(곡톡 μ§€μΉ¨ κ΄€λ¦¬μž)μ—μ„œ 받은 TTL을 λŸ°νƒ€μž„μ— 보관. 첫 쑰회 μ „μ—” κΈ°λ³Έκ°’ μ‚¬μš©. _RUNTIME_TTL: float | None = None def _eff_ttl() -> float: """μΊμ‹œ 유효 μ‹œκ°„(초). DBμ—μ„œ 받은 κ°’ μš°μ„ , μ—†μœΌλ©΄ κΈ°λ³Έκ°’.""" return _RUNTIME_TTL if _RUNTIME_TTL is not None else _GUIDELINES_TTL def invalidate_guidelines_cache() -> None: """곡톡 μ§€μΉ¨ μΊμ‹œλ₯Ό μ¦‰μ‹œ λΉ„μš΄λ‹€. Aegisκ°€ μ§€μΉ¨ μ €μž₯ 직후 λ¬΄νš¨ν™” ν›…μœΌλ‘œ 호좜 β†’ λ‹€μŒ λ©”μ‹œμ§€ 1λ²ˆμ— μƒˆ 지침이 λ°”λ‘œ λ°˜μ˜λœλ‹€(νŽΈμ§‘ ν›„ '두 번 말 κ±ΈκΈ°' 뢈편 ν•΄μ†Œ). TTL은 μ•ˆμ „λ§μœΌλ‘œλ§Œ 남고, ν‰μ†Œμ—” 이 훅이 갱신을 λ‹΄λ‹Ήν•œλ‹€.""" _GUIDELINES_CACHE.clear() _emotion_logger.info("[Guidelines] μΊμ‹œ λ¬΄νš¨ν™” β€” λ‹€μŒ μ‘°νšŒμ—μ„œ μ΅œμ‹  지침을 λ‹€μ‹œ κ°€μ Έμ˜΅λ‹ˆλ‹€.") _FETCH_FAIL_CACHE: dict = {} # aegis_url β†’ μ‹€νŒ¨ μ‹œκ° (λ„€κ±°ν‹°λΈŒ μΊμ‹œ) def _fetch_guidelines_from_aegis(aegis_url: str) -> dict | None: """Aegis REST APIμ—μ„œ 곡톡 지침을 κ°€μ Έμ˜΅λ‹ˆλ‹€. μ‹€νŒ¨ μ‹œ None λ°˜ν™˜. ⚠️ GET /api/system/guidelines λŠ” 인증을 μš”κ΅¬ν•œλ‹€(_require_auth). 토큰 없이 ν˜ΈμΆœν•˜λ©΄ 401 β†’ None β†’ ν•˜λ“œμ½”λ”© 폴백이 μ“°μ—¬, '곡톡 μ§€μΉ¨ κ΄€λ¦¬μž' νŽΈμ§‘μ΄ λΈŒλ ˆμΈμ— μ „ν˜€ λ°˜μ˜λ˜μ§€ μ•ŠλŠ”λ‹€. λ°˜λ“œμ‹œ μ„œλΉ„μŠ€ 토큰을 μ‹€μ–΄ 보낸닀(μ €μž₯ 경둜 get_job_perf_flags와 동일 νŒ¨ν„΄).""" global _RUNTIME_TTL # πŸ›‘οΈ μ‹€νŒ¨ λ„€κ±°ν‹°λΈŒ μΊμ‹œ β€” 401/λ„€νŠΈμ›Œν¬ μ‹€νŒ¨κ°€ λ°˜λ³΅λ˜λŠ” λ™μ•ˆ λͺ¨λ“  μ†ŒλΉ„μž # (retrieval_policy/emotion/readability)κ°€ λ§€ ν„΄ ν΄λΌμš°λ“œ 왕볡을 λ°˜λ³΅ν•˜μ§€ μ•Šκ²Œ. _fail_at = _FETCH_FAIL_CACHE.get(aegis_url) if _fail_at and (time.time() - _fail_at) < _eff_ttl(): return None try: import os import requests headers = {} tok = os.environ.get("IMPERIAL_API_TOKEN") or os.environ.get("SHADOWBRAIN_TOKEN") if tok: headers["X-Imperial-Token"] = tok headers["Authorization"] = f"Bearer {tok}" url = aegis_url.rstrip('/') + '/api/system/guidelines' resp = requests.get(url, headers=headers, timeout=3) if resp.status_code == 200: data = resp.json() # ⏱️ κ΄€λ¦¬μžμ—μ„œ μ„€μ •ν•œ μΊμ‹œ TTL을 λŸ°νƒ€μž„μ— 반영(λ‹€μŒ μΊμ‹œ νŒμ •λΆ€ν„° 적용). if isinstance(data, dict) and data.get('cache_ttl_seconds') is not None: try: _RUNTIME_TTL = float(data['cache_ttl_seconds']) except (TypeError, ValueError): pass _FETCH_FAIL_CACHE.pop(aegis_url, None) return data # {"readability": "...", "emotion": "...", "cache_ttl_seconds": N} _emotion_logger.debug(f"[Guidelines] Aegis HTTP {resp.status_code} β€” λ„€κ±°ν‹°λΈŒ μΊμ‹œ") except Exception as e: _emotion_logger.debug(f"[Guidelines] Aegis 쑰회 μ‹€νŒ¨ (폴백 μ‚¬μš©): {e}") _FETCH_FAIL_CACHE[aegis_url] = time.time() return None def get_dynamic_retrieval_policy(aegis_url: str | None = None) -> dict: """DB 곡톡 검색 μ •μ±…. 쑰회 μ‹€νŒ¨/λˆ„λ½ μ‹œ μžλ™ 검색을 μ€‘λ‹¨ν•œλ‹€.""" if not aegis_url: return {} cache_key = f"{aegis_url}:retrieval_policy" cached = _GUIDELINES_CACHE.get(cache_key) if cached and (time.time() - cached['ts']) < _eff_ttl(): return cached['value'] data = _fetch_guidelines_from_aegis(aegis_url) value = data.get('retrieval_policy') if data else None if isinstance(value, dict): _GUIDELINES_CACHE[cache_key] = {'value': value, 'ts': time.time()} return value # πŸ›‘οΈ λ„€κ±°ν‹°λΈŒ μΊμ‹œ β€” Aegis μ‹€νŒ¨/λˆ„λ½λ„ TTL λ™μ•ˆ κΈ°μ–΅. μ•ˆ ν•˜λ©΄ μ˜λ„ 감지 헬퍼 # (_tool_policy/_intent_cfg λ“±)κ°€ λ§€ ν„΄ ν΄λΌμš°λ“œ 왕볡(~840ms)을 λ°˜λ³΅ν•΄ TTFTλ₯Ό # 수 μ΄ˆμ”© νƒœμš΄λ‹€(2026-07-10 μ‹€μΈ‘). _GUIDELINES_CACHE[cache_key] = {'value': {}, 'ts': time.time()} return {} def get_dynamic_emotion_instruction(aegis_url: str | None = None) -> str: """ 감정 μ—°κΈ° 지침을 λ°˜ν™˜ν•©λ‹ˆλ‹€. DB(Aegis '곡톡 μ§€μΉ¨ κ΄€λ¦¬μž')κ°€ 'μœ μΌν•œ μ§„μ‹€'. - aegis_url 제곡 + 쑰회 성곡 μ‹œ: DB κ°’(+ ν† κΈ€ 켜쑌으면 말투 μ˜ˆμ‹œ) λ°˜ν™˜. - aegis_url μ—†κ±°λ‚˜ 쑰회 μ‹€νŒ¨ μ‹œ: 빈 λ¬Έμžμ—΄(μ£Όμž… μ•ˆ 함). ν•˜λ“œμ½”λ”© 폴백 μ—†μŒ β€” μ˜ˆμ „μ—” 폴백이 '쑰용히 λ‹€λ₯Έ κ°’'을 μ£Όμž…ν•΄ κ΄€λ¦¬μž νŽΈμ§‘μ΄ μ•ˆ λ¨ΉνžˆλŠ” footgunμ΄μ—ˆλ‹€. """ if not aegis_url: return "" cache_key = f"{aegis_url}:emotion" cached = _GUIDELINES_CACHE.get(cache_key) if cached and (time.time() - cached['ts']) < _eff_ttl(): return cached['value'] data = _fetch_guidelines_from_aegis(aegis_url) if data and 'emotion' in data and data['emotion'] is not None: value = data['emotion'] # 🎭 말투 μ˜ˆμ‹œλŠ” 별도 ν•„λ“œ + ν† κΈ€(emotion_example_enabled). μΌœμ‘Œμ„ λ•Œλ§Œ 덧뢙인닀. if data.get('emotion_example_enabled', True): value = _append_emotion_example(value, data.get('emotion_example')) _GUIDELINES_CACHE[cache_key] = {'value': value, 'ts': time.time()} return value return "" def get_dynamic_readability_instruction(aegis_url: str | None = None) -> str: """ 가독성 지침을 λ°˜ν™˜ν•©λ‹ˆλ‹€. DB(Aegis '곡톡 μ§€μΉ¨ κ΄€λ¦¬μž')κ°€ 'μœ μΌν•œ μ§„μ‹€'. - aegis_url 제곡 + 쑰회 성곡 μ‹œ: DB κ°’ λ°˜ν™˜. - aegis_url μ—†κ±°λ‚˜ 쑰회 μ‹€νŒ¨ μ‹œ: 빈 λ¬Έμžμ—΄(μ£Όμž… μ•ˆ 함). ν•˜λ“œμ½”λ”© 폴백 μ—†μŒ. """ if not aegis_url: return "" cache_key = f"{aegis_url}:readability" cached = _GUIDELINES_CACHE.get(cache_key) if cached and (time.time() - cached['ts']) < _eff_ttl(): return cached['value'] data = _fetch_guidelines_from_aegis(aegis_url) if data and 'readability' in data and data['readability'] is not None: value = data['readability'] _GUIDELINES_CACHE[cache_key] = {'value': value, 'ts': time.time()} return value return "" def extract_emotion(text: str, default: str = 'peaceful') -> str: """AI μ‘λ‹΅μ—μ„œ 감정값을 μΆ”μΆœν•©λ‹ˆλ‹€. [EMOTION:xxx] μš°μ„ , λ ˆκ±°μ‹œ 인라인 폴백.""" if not text: return default matches = EMOTION_BOARD_RE.findall(text) if matches: return matches[-1].strip().lower() matches = INLINE_EMOTION_RE.findall(text) if matches: return matches[-1].lower() return default def strip_emotion_tags(text: str) -> str: """ν‘œμ‹œμš© ν…μŠ€νŠΈμ—μ„œ λͺ¨λ“  감정 νƒœκ·Έλ₯Ό μ œκ±°ν•©λ‹ˆλ‹€. extract_emotion ν›„ ν˜ΈμΆœν•˜μ‹­μ‹œμ˜€.""" t = EMOTION_BOARD_RE.sub('', text) t = INLINE_EMOTION_RE.sub('', t) return t.strip()