from __future__ import annotations """Intent Inference 통합. 흐름: - infer_batch(survey) : Batch Feature만으로 baseline Intent Score 산출 - infer_with_behavior : Batch + Behavioral Pattern Feature를 합쳐 재추론 (boost 누적 방식이 아니라, 모든 피처를 입력으로 다시 모델/룰을 통과시킴) 산출되는 IntentScore는 baseline 대비 변화량(delta_score, rank_change)을 함께 보관한다. """ import logging import math from dataclasses import dataclass from typing import Any # Softmax 분포 sharpness 조절. 작을수록 Top 점수에 분포가 집중. # T=0.15 → 시연 임팩트(상위 Intent 강조) 우선. Top 5가 분포의 ~60% 점유, 행동 1번에 Δp 수%p PROBABILITY_TEMPERATURE = 0.15 from config import settings from core.engines import get_engine, config from core.engines.base import ScenarioEngine from core.extractor import get_extractor logger = logging.getLogger(__name__) # 행동 → 직접 신호 Intent 부스트. (시나리오 config L2_inference.ranker.action_signal로 override) # Rule pattern_boost가 일부 행동만 커버하는 한계를 보완해, behaviors.json의 모든 행동이 # 의미상 연결된 Intent를 끌어올리도록 한다. (final 재추론에만 적용; baseline은 batch만) # 아래 값은 scenario_id 미지정/누락 시 fallback 기본값. ACTION_SIGNAL_SCALE = 0.28 # 최신 행동 1회당 가산 (weight=1.0 기준) ACTION_SIGNAL_CAP = 0.55 # 행동 반복 시 상한 ACTION_SIGNAL_DECAY = 0.6 # 위치 기반 recency 감쇠 (최신 age=0 → 1.0, 직전 0.6, 그전 0.36 …) def _resolve_temperature(scenario_id: str | None) -> float: """시나리오 config의 softmax 온도를 조회한다. Args: scenario_id: 조회할 시나리오 ID. None이면 모듈 기본값을 사용한다. Returns: softmax 온도. config 누락 시 모듈 기본값으로 fallback한다. """ if scenario_id is None: return PROBABILITY_TEMPERATURE try: return config.get_probability_temperature(scenario_id) except (KeyError, FileNotFoundError): return PROBABILITY_TEMPERATURE def _resolve_action_signal(scenario_id: str | None) -> tuple[float, float, float]: """시나리오 config의 행동 부스트 파라미터를 조회한다. Args: scenario_id: 조회할 시나리오 ID. None이면 모듈 기본값을 사용한다. Returns: (scale, cap, decay) 튜플. config 누락 시 모듈 기본값으로 fallback한다. """ if scenario_id is not None: try: sig = config.get_action_signal(scenario_id) return sig["scale"], sig["cap"], sig["decay"] except (KeyError, FileNotFoundError): pass return ACTION_SIGNAL_SCALE, ACTION_SIGNAL_CAP, ACTION_SIGNAL_DECAY def _resolve_boost_mode(scenario_id: str | None) -> str: """행동 boost 합성 방식을 조회한다. config L2.ranker.action_signal.boost_mode 값을 사용한다. Args: scenario_id: 조회할 시나리오 ID. None이면 기본값을 사용한다. Returns: 합성 방식 문자열. 누락 시 'additive'로 fallback한다. """ if scenario_id is not None: try: return config.get_action_signal(scenario_id).get("boost_mode", "additive") except (KeyError, FileNotFoundError): pass return "additive" def _resolve_action_suppress(scenario_id: str | None) -> dict | None: """행동 기반 의도 감쇠 설정을 조회한다. config L2.ranker.action_signal.suppress 값을 사용한다. 형식: {"by_entity": {entity: [감쇠 대상 intent_id, ...]}, "scale": float, "cap": float}. 예) 회복 행동(mental_recovery/exercise) 누적 시 번아웃 심화 intent를 점진 감쇠. Args: scenario_id: 조회할 시나리오 ID. None이면 None을 반환한다. Returns: 감쇠 설정 dict. 미설정 시 None. """ if scenario_id is not None: try: return config.get_action_signal(scenario_id).get("suppress") except (KeyError, FileNotFoundError): pass return None def _action_intent_signals( events: list[dict], behavior_map: dict[str, list[str]], decay: float = ACTION_SIGNAL_DECAY, ) -> dict[str, float]: """세션 누적 행동을 entity→intent 매핑으로 의도별 가중 신호로 환산한다. - recency decay: 최신 행동일수록 큰 weight (DECAY^age). 방금 한 행동이 현재 의도를 주도하되, 같은 행동 반복은 누적되어 강해진다(과거도 0으로 죽이진 않음). - BACK(navigate_back)은 메뉴 복귀용 순수 내비게이션 → 신호·aging 모두에서 제외(무효과). 섹션을 떠난 행동은 이후 다른 행동이 쌓이며 decay로 자연 소멸한다. Args: events: 세션에 누적된 행동 이벤트 리스트. behavior_map: entity → intent_id 리스트 매핑. decay: 위치 기반 recency 감쇠 계수. Returns: intent_id → 가중 신호 합 매핑. """ real = [ev for ev in events if ev.get("event_type") != "navigate_back"] n = len(real) weights: dict[str, float] = {} for i, ev in enumerate(real): age = (n - 1) - i w = decay ** age for iid in behavior_map.get(ev.get("entity", ""), []): weights[iid] = weights.get(iid, 0.0) + w return weights @dataclass class IntentScore: """단일 intent의 baseline·final 점수와 순위 변화(행동 반영 전후).""" intent_id: str intent_name: str L1_id: str L1_name: str L2_id: str L2_name: str inference_type: str baseline_score: float # Batch Feature만으로 추론한 점수 final_score: float # Batch + Behavioral Pattern Feature 합쳐 재추론한 점수 delta_score: float # final - baseline baseline_rank: int # baseline 기준 rank rank: int # final_score 기준 rank rank_change: int # baseline_rank - rank (양수면 상승) def infer_batch( survey_answers: dict[str, str], scenario_id: str = settings.SCENARIO_ID, ) -> tuple[dict[str, Any], list[IntentScore]]: """설문 답변만으로 baseline Intent Score를 산출한다 (행동 반영 전). Args: survey_answers: 질문 ID → 선택 응답 코드 매핑. scenario_id: 추론에 사용할 시나리오 ID. Returns: (batch_features, scores) 튜플. batch_features는 산출된 Batch Feature, scores는 final 점수 내림차순으로 정렬된 IntentScore 리스트. """ engine = get_engine(scenario_id) batch_features = engine.build_batch_features(survey_answers) all_features = { **batch_features, **engine.empty_pattern_features(), **engine.empty_event_features(), } raw = _score_all(all_features, engine) scores = _to_intent_scores(raw, raw, engine) return batch_features, scores def infer_with_behavior( survey_answers: dict[str, str], session_id: str, scenario_id: str = settings.SCENARIO_ID, ) -> tuple[dict[str, Any], list[IntentScore]]: """Batch + 누적 Pattern + 최신 Event Feature를 합쳐 재추론한다. baseline(행동 없는 상태) 점수를 함께 산출해 delta_score / rank_change를 채운다. Args: survey_answers: 질문 ID → 선택 응답 코드 매핑. session_id: 누적 행동 이벤트를 조회할 세션 ID. scenario_id: 추론에 사용할 시나리오 ID. Returns: (batch_features, scores) 튜플. scores는 baseline 대비 델타·순위변화가 채워진 IntentScore 리스트로, final 점수 내림차순으로 정렬된다. """ engine = get_engine(scenario_id) batch_features = engine.build_batch_features(survey_answers) # baseline: Pattern/Event Feature를 0으로 둔 상태 baseline_features = { **batch_features, **engine.empty_pattern_features(), **engine.empty_event_features(), } baseline_raw = _score_all(baseline_features, engine) # final: 실제 누적 Pattern + 최신 Event Feature 반영 (엔진 전용 계산) pattern_features = engine.pattern_features(session_id) event_features = engine.event_features(session_id) events = get_extractor()._events_by_session.get(session_id, []) combined_features = {**batch_features, **pattern_features, **event_features} final_raw = _score_all(combined_features, engine) # 행동이 직접 가리키는 Intent를 끌어올림 (final 에만 적용 → Δ·rank_change가 행동에 귀속) scale, cap, decay = _resolve_action_signal(scenario_id) boost_mode = _resolve_boost_mode(scenario_id) # "additive"(기본) | "headroom" behavior_map = engine.behavior_intent_map() for iid, cnt in _action_intent_signals(events, behavior_map, decay=decay).items(): if iid in final_raw: boost = min(cnt * scale, cap) if boost_mode == "headroom": # 헤드룸 비례 가산: 천장(0.97) 다중 동점 방지 + base 순서 보존 final_raw[iid] = final_raw[iid] + boost * (1.0 - final_raw[iid]) else: final_raw[iid] = min(final_raw[iid] + boost, 0.97) # 행동 기반 감쇠: 특정 행동(예: 회복) 누적 시 대상 intent(예: 번아웃 심화)를 점진 감쇠. # 가산 boost와 대칭 — 같은 recency decay 가중을 penalty로 환산해 점수를 비율 축소. suppress = _resolve_action_suppress(scenario_id) if suppress: sup_scale = suppress.get("scale", 0.0) sup_cap = suppress.get("cap", 1.0) sup_map = suppress.get("by_entity", {}) for iid, cnt in _action_intent_signals(events, sup_map, decay=decay).items(): if iid in final_raw: penalty = min(cnt * sup_scale, sup_cap) final_raw[iid] = final_raw[iid] * (1.0 - penalty) scores = _to_intent_scores(baseline_raw, final_raw, engine) return batch_features, scores def _score_all(features: dict[str, Any], engine: ScenarioEngine) -> dict[str, float]: """모든 Intent에 대해 점수만 산출한다. inference_type에 따라 rule/model로 분기한다. Args: features: 추론 입력 feature 매핑. engine: 시나리오 엔진. Returns: intent_id → score 매핑. """ f = dict(features) if isinstance(f.get("결합 여부"), bool): f["결합 여부"] = 1 if f["결합 여부"] else 0 out: dict[str, float] = {} for intent in engine.intents(): iid = intent["id"] if intent["inference_type"] == "Model": score = engine.model_predict(iid, f) else: score = engine.rule_predict(iid, f) out[iid] = float(score) return out def _rank_map(raw: dict[str, float]) -> dict[str, int]: """raw 점수를 내림차순 순위로 환산한다. Args: raw: intent_id → score 매핑. Returns: intent_id → 순위(1-기반) 매핑. """ ordered = sorted(raw.items(), key=lambda kv: kv[1], reverse=True) return {iid: i for i, (iid, _) in enumerate(ordered, start=1)} def _to_intent_scores( baseline_raw: dict[str, float], final_raw: dict[str, float], engine: ScenarioEngine, ) -> list[IntentScore]: """baseline·final raw 점수를 IntentScore 리스트로 변환한다. 델타·순위변화를 채우고 final 점수 내림차순으로 정렬한다. Args: baseline_raw: baseline intent_id → score 매핑. final_raw: final intent_id → score 매핑. engine: 시나리오 엔진. Returns: final 점수 내림차순으로 정렬된 IntentScore 리스트. """ baseline_ranks = _rank_map(baseline_raw) final_ranks = _rank_map(final_raw) results: list[IntentScore] = [] for intent in engine.intents(): iid = intent["id"] b = baseline_raw.get(iid, 0.0) f = final_raw.get(iid, 0.0) br = baseline_ranks.get(iid, 0) fr = final_ranks.get(iid, 0) results.append(IntentScore( intent_id=iid, intent_name=intent["name"], L1_id=intent["L1_id"], L1_name=intent["L1_name"], L2_id=intent["L2_id"], L2_name=intent["L2_name"], inference_type=intent["inference_type"], baseline_score=round(b, 4), final_score=round(f, 4), delta_score=round(f - b, 4), baseline_rank=br, rank=fr, rank_change=br - fr, )) results.sort(key=lambda s: s.final_score, reverse=True) return results # ── WebSocket 페이로드 헬퍼 ─────────────────────────────────── def _softmax(values: list[float], temperature: float) -> list[float]: """수치적으로 안정적인 softmax로 raw score를 정규화 확률 분포로 변환한다. Args: values: 정규화할 raw score 리스트. temperature: softmax 온도. 작을수록 상위 값에 분포가 집중된다. Returns: 합이 1이 되는 정규화 확률 리스트. 입력이 비면 빈 리스트. """ if not values: return [] scaled = [v / temperature for v in values] m = max(scaled) exps = [math.exp(s - m) for s in scaled] total = sum(exps) or 1.0 return [e / total for e in exps] def to_probability_dict( scores: list[IntentScore], scenario_id: str | None = None, temperature: float | None = None, ) -> dict[str, dict[str, float]]: """Intent raw score를 softmax 정규화 확률(p)과 baseline 확률(p0)로 변환한다. raw score 합 분모 정규화는 분포가 너무 평탄하므로 softmax(score / T) 분포를 사용한다. T가 작을수록 상위 Intent에 분포가 집중된다. T는 scenario_id의 config(L2_inference.calibrator)에서 조회한다(미지정 시 모듈 기본값). INTENT_UPDATE 페이로드의 `all_probabilities` 필드에 사용된다. Args: scores: 확률로 변환할 IntentScore 리스트. scenario_id: 온도 조회에 사용할 시나리오 ID. None이면 모듈 기본값. temperature: softmax 온도 직접 지정. None이면 scenario_id로 조회한다. Returns: intent_id → {"p": final 확률, "p0": baseline 확률} 매핑. """ if temperature is None: temperature = _resolve_temperature(scenario_id) p_vals = _softmax([s.final_score for s in scores], temperature) p0_vals = _softmax([s.baseline_score for s in scores], temperature) return { s.intent_id: { "p": round(p_vals[i], 6), "p0": round(p0_vals[i], 6), } for i, s in enumerate(scores) } def to_topn_with_others( scores: list[IntentScore], top_n: int = 5, scenario_id: str | None = None, ) -> tuple[list[dict], dict]: """Top-N + 기타(others) 페이로드를 구성한다. Args: scores: 확률로 변환할 IntentScore 리스트. top_n: 상위로 노출할 intent 개수. scenario_id: 온도 조회에 사용할 시나리오 ID. None이면 모듈 기본값. Returns: (top_list, others) 튜플. top_list: [ {intent_id, ..., probability, baseline_probability, delta_probability} ] others: { count, probability, baseline_probability, delta_probability } """ probs = to_probability_dict(scores, scenario_id=scenario_id) sorted_scores = sorted(scores, key=lambda s: s.final_score, reverse=True) top_items: list[dict] = [] for s in sorted_scores[:top_n]: pr = probs[s.intent_id] top_items.append({ "intent_id": s.intent_id, "intent_nm_ko": s.intent_name, "L1_id": s.L1_id, "L1_name": s.L1_name, "L2_id": s.L2_id, "L2_name": s.L2_name, "inference_type": s.inference_type, "rank": s.rank, "baseline_rank": s.baseline_rank, "rank_change": s.rank_change, "probability": pr["p"], "baseline_probability": pr["p0"], "delta_probability": round(pr["p"] - pr["p0"], 6), }) rest = sorted_scores[top_n:] others_p = sum(probs[s.intent_id]["p"] for s in rest) others_p0 = sum(probs[s.intent_id]["p0"] for s in rest) others = { "count": len(rest), "probability": round(others_p, 6), "baseline_probability": round(others_p0, 6), "delta_probability": round(others_p - others_p0, 6), } return top_items, others # ── Customer Context JSON 생성 ──────────────────────────────── def to_customer_context_json( session_id: str, stage: str, scenario_id: str, scores: list[IntentScore], batch_features: dict[str, Any], ) -> dict[str, Any]: """세션·단계·전체 intent 점수를 Customer Context JSON으로 직렬화한다. 저장/조회용 페이로드를 구성한다. Args: session_id: 세션 ID. stage: 추론 단계. scenario_id: 시나리오 ID. scores: 직렬화할 IntentScore 리스트. batch_features: 산출된 Batch Feature 매핑. Returns: session/scenario/stage/intents 키를 가진 Customer Context JSON dict. """ intents = [] for s in scores: intents.append({ "intent_id": s.intent_id, "intent_nm_ko": s.intent_name, "L1": {"id": s.L1_id, "name": s.L1_name}, "L2": {"id": s.L2_id, "name": s.L2_name}, "baseline_score": s.baseline_score, "final_score": s.final_score, "delta_score": s.delta_score, "baseline_rank": s.baseline_rank, "rank": s.rank, "rank_change": s.rank_change, "inference_type": s.inference_type, }) return { "session_id": session_id, "scenario_id": scenario_id, "stage": stage, "intents": intents, }