CoolFace
Apppublic

matalee/hpce-dev

sourceHugging Faceapache-2.0updated 3mo agoView on Hugging Face
0likes
inference.py492 linesDownload Raw Back to core
1from __future__ import annotations2"""Intent Inference 통합.3 4흐름:5  - infer_batch(survey)  : Batch Feature만으로 baseline Intent Score 산출6  - infer_with_behavior  : Batch + Behavioral Pattern Feature를 합쳐 재추론7                          (boost 누적 방식이 아니라, 모든 피처를 입력으로 다시 모델/룰을 통과시킴)8 9산출되는 IntentScore는 baseline 대비 변화량(delta_score, rank_change)을 함께 보관한다.10"""11import logging12import math13from dataclasses import dataclass14from typing import Any15 16# Softmax 분포 sharpness 조절. 작을수록 Top 점수에 분포가 집중.17# T=0.15 → 시연 임팩트(상위 Intent 강조) 우선. Top 5가 분포의 ~60% 점유, 행동 1번에 Δp 수%p18PROBABILITY_TEMPERATURE = 0.1519 20from config import settings21from core.engines import get_engine, config22from core.engines.base import ScenarioEngine23from core.extractor import get_extractor24 25logger = logging.getLogger(__name__)26 27# 행동 → 직접 신호 Intent 부스트. (시나리오 config L2_inference.ranker.action_signal로 override)28# Rule pattern_boost가 일부 행동만 커버하는 한계를 보완해, behaviors.json의 모든 행동이29# 의미상 연결된 Intent를 끌어올리도록 한다. (final 재추론에만 적용; baseline은 batch만)30# 아래 값은 scenario_id 미지정/누락 시 fallback 기본값.31ACTION_SIGNAL_SCALE = 0.28   # 최신 행동 1회당 가산 (weight=1.0 기준)32ACTION_SIGNAL_CAP   = 0.55   # 행동 반복 시 상한33ACTION_SIGNAL_DECAY = 0.6    # 위치 기반 recency 감쇠 (최신 age=0 → 1.0, 직전 0.6, 그전 0.36 …)34 35 36def _resolve_temperature(scenario_id: str | None) -> float:37    """시나리오 config의 softmax 온도를 조회한다.38 39    Args:40        scenario_id: 조회할 시나리오 ID. None이면 모듈 기본값을 사용한다.41 42    Returns:43        softmax 온도. config 누락 시 모듈 기본값으로 fallback한다.44    """45    if scenario_id is None:46        return PROBABILITY_TEMPERATURE47    try:48        return config.get_probability_temperature(scenario_id)49    except (KeyError, FileNotFoundError):50        return PROBABILITY_TEMPERATURE51 52 53def _resolve_action_signal(scenario_id: str | None) -> tuple[float, float, float]:54    """시나리오 config의 행동 부스트 파라미터를 조회한다.55 56    Args:57        scenario_id: 조회할 시나리오 ID. None이면 모듈 기본값을 사용한다.58 59    Returns:60        (scale, cap, decay) 튜플. config 누락 시 모듈 기본값으로 fallback한다.61    """62    if scenario_id is not None:63        try:64            sig = config.get_action_signal(scenario_id)65            return sig["scale"], sig["cap"], sig["decay"]66        except (KeyError, FileNotFoundError):67            pass68    return ACTION_SIGNAL_SCALE, ACTION_SIGNAL_CAP, ACTION_SIGNAL_DECAY69 70 71def _resolve_boost_mode(scenario_id: str | None) -> str:72    """행동 boost 합성 방식을 조회한다.73 74    config L2.ranker.action_signal.boost_mode 값을 사용한다.75 76    Args:77        scenario_id: 조회할 시나리오 ID. None이면 기본값을 사용한다.78 79    Returns:80        합성 방식 문자열. 누락 시 'additive'로 fallback한다.81    """82    if scenario_id is not None:83        try:84            return config.get_action_signal(scenario_id).get("boost_mode", "additive")85        except (KeyError, FileNotFoundError):86            pass87    return "additive"88 89 90def _resolve_action_suppress(scenario_id: str | None) -> dict | None:91    """행동 기반 의도 감쇠 설정을 조회한다.92 93    config L2.ranker.action_signal.suppress 값을 사용한다.94    형식: {"by_entity": {entity: [감쇠 대상 intent_id, ...]}, "scale": float, "cap": float}.95    예) 회복 행동(mental_recovery/exercise) 누적 시 번아웃 심화 intent를 점진 감쇠.96 97    Args:98        scenario_id: 조회할 시나리오 ID. None이면 None을 반환한다.99 100    Returns:101        감쇠 설정 dict. 미설정 시 None.102    """103    if scenario_id is not None:104        try:105            return config.get_action_signal(scenario_id).get("suppress")106        except (KeyError, FileNotFoundError):107            pass108    return None109 110 111def _action_intent_signals(112    events: list[dict],113    behavior_map: dict[str, list[str]],114    decay: float = ACTION_SIGNAL_DECAY,115) -> dict[str, float]:116    """세션 누적 행동을 entity→intent 매핑으로 의도별 가중 신호로 환산한다.117 118    - recency decay: 최신 행동일수록 큰 weight (DECAY^age). 방금 한 행동이 현재 의도를 주도하되,119      같은 행동 반복은 누적되어 강해진다(과거도 0으로 죽이진 않음).120    - BACK(navigate_back)은 메뉴 복귀용 순수 내비게이션 → 신호·aging 모두에서 제외(무효과).121      섹션을 떠난 행동은 이후 다른 행동이 쌓이며 decay로 자연 소멸한다.122 123    Args:124        events: 세션에 누적된 행동 이벤트 리스트.125        behavior_map: entity → intent_id 리스트 매핑.126        decay: 위치 기반 recency 감쇠 계수.127 128    Returns:129        intent_id → 가중 신호 합 매핑.130    """131    real = [ev for ev in events if ev.get("event_type") != "navigate_back"]132    n = len(real)133 134    weights: dict[str, float] = {}135    for i, ev in enumerate(real):136        age = (n - 1) - i137        w = decay ** age138        for iid in behavior_map.get(ev.get("entity", ""), []):139            weights[iid] = weights.get(iid, 0.0) + w140    return weights141 142 143@dataclass144class IntentScore:145    """단일 intent의 baseline·final 점수와 순위 변화(행동 반영 전후)."""146    intent_id:       str147    intent_name:     str148    L1_id:           str149    L1_name:         str150    L2_id:           str151    L2_name:         str152    inference_type:  str153    baseline_score:  float   # Batch Feature만으로 추론한 점수154    final_score:     float   # Batch + Behavioral Pattern Feature 합쳐 재추론한 점수155    delta_score:     float   # final - baseline156    baseline_rank:   int     # baseline 기준 rank157    rank:            int     # final_score 기준 rank158    rank_change:     int     # baseline_rank - rank (양수면 상승)159 160 161def infer_batch(162    survey_answers: dict[str, str],163    scenario_id: str = settings.SCENARIO_ID,164) -> tuple[dict[str, Any], list[IntentScore]]:165    """설문 답변만으로 baseline Intent Score를 산출한다 (행동 반영 전).166 167    Args:168        survey_answers: 질문 ID → 선택 응답 코드 매핑.169        scenario_id: 추론에 사용할 시나리오 ID.170 171    Returns:172        (batch_features, scores) 튜플. batch_features는 산출된 Batch Feature,173        scores는 final 점수 내림차순으로 정렬된 IntentScore 리스트.174    """175    engine = get_engine(scenario_id)176    batch_features = engine.build_batch_features(survey_answers)177    all_features = {178        **batch_features,179        **engine.empty_pattern_features(),180        **engine.empty_event_features(),181    }182 183    raw = _score_all(all_features, engine)184    scores = _to_intent_scores(raw, raw, engine)185    return batch_features, scores186 187 188def infer_with_behavior(189    survey_answers: dict[str, str],190    session_id: str,191    scenario_id: str = settings.SCENARIO_ID,192) -> tuple[dict[str, Any], list[IntentScore]]:193    """Batch + 누적 Pattern + 최신 Event Feature를 합쳐 재추론한다.194 195    baseline(행동 없는 상태) 점수를 함께 산출해 delta_score / rank_change를 채운다.196 197    Args:198        survey_answers: 질문 ID → 선택 응답 코드 매핑.199        session_id: 누적 행동 이벤트를 조회할 세션 ID.200        scenario_id: 추론에 사용할 시나리오 ID.201 202    Returns:203        (batch_features, scores) 튜플. scores는 baseline 대비 델타·순위변화가 채워진204        IntentScore 리스트로, final 점수 내림차순으로 정렬된다.205    """206    engine = get_engine(scenario_id)207    batch_features = engine.build_batch_features(survey_answers)208 209    # baseline: Pattern/Event Feature를 0으로 둔 상태210    baseline_features = {211        **batch_features,212        **engine.empty_pattern_features(),213        **engine.empty_event_features(),214    }215    baseline_raw = _score_all(baseline_features, engine)216 217    # final: 실제 누적 Pattern + 최신 Event Feature 반영 (엔진 전용 계산)218    pattern_features = engine.pattern_features(session_id)219    event_features = engine.event_features(session_id)220    events = get_extractor()._events_by_session.get(session_id, [])221 222    combined_features = {**batch_features, **pattern_features, **event_features}223    final_raw = _score_all(combined_features, engine)224 225    # 행동이 직접 가리키는 Intent를 끌어올림 (final 에만 적용 → Δ·rank_change가 행동에 귀속)226    scale, cap, decay = _resolve_action_signal(scenario_id)227    boost_mode = _resolve_boost_mode(scenario_id)   # "additive"(기본) | "headroom"228    behavior_map = engine.behavior_intent_map()229    for iid, cnt in _action_intent_signals(events, behavior_map, decay=decay).items():230        if iid in final_raw:231            boost = min(cnt * scale, cap)232            if boost_mode == "headroom":233                # 헤드룸 비례 가산: 천장(0.97) 다중 동점 방지 + base 순서 보존234                final_raw[iid] = final_raw[iid] + boost * (1.0 - final_raw[iid])235            else:236                final_raw[iid] = min(final_raw[iid] + boost, 0.97)237 238    # 행동 기반 감쇠: 특정 행동(예: 회복) 누적 시 대상 intent(예: 번아웃 심화)를 점진 감쇠.239    # 가산 boost와 대칭 — 같은 recency decay 가중을 penalty로 환산해 점수를 비율 축소.240    suppress = _resolve_action_suppress(scenario_id)241    if suppress:242        sup_scale = suppress.get("scale", 0.0)243        sup_cap   = suppress.get("cap", 1.0)244        sup_map   = suppress.get("by_entity", {})245        for iid, cnt in _action_intent_signals(events, sup_map, decay=decay).items():246            if iid in final_raw:247                penalty = min(cnt * sup_scale, sup_cap)248                final_raw[iid] = final_raw[iid] * (1.0 - penalty)249 250    scores = _to_intent_scores(baseline_raw, final_raw, engine)251    return batch_features, scores252 253 254def _score_all(features: dict[str, Any], engine: ScenarioEngine) -> dict[str, float]:255    """모든 Intent에 대해 점수만 산출한다.256 257    inference_type에 따라 rule/model로 분기한다.258 259    Args:260        features: 추론 입력 feature 매핑.261        engine: 시나리오 엔진.262 263    Returns:264        intent_id → score 매핑.265    """266    f = dict(features)267    if isinstance(f.get("결합 여부"), bool):268        f["결합 여부"] = 1 if f["결합 여부"] else 0269 270    out: dict[str, float] = {}271    for intent in engine.intents():272        iid = intent["id"]273        if intent["inference_type"] == "Model":274            score = engine.model_predict(iid, f)275        else:276            score = engine.rule_predict(iid, f)277        out[iid] = float(score)278    return out279 280 281def _rank_map(raw: dict[str, float]) -> dict[str, int]:282    """raw 점수를 내림차순 순위로 환산한다.283 284    Args:285        raw: intent_id → score 매핑.286 287    Returns:288        intent_id → 순위(1-기반) 매핑.289    """290    ordered = sorted(raw.items(), key=lambda kv: kv[1], reverse=True)291    return {iid: i for i, (iid, _) in enumerate(ordered, start=1)}292 293 294def _to_intent_scores(295    baseline_raw: dict[str, float],296    final_raw: dict[str, float],297    engine: ScenarioEngine,298) -> list[IntentScore]:299    """baseline·final raw 점수를 IntentScore 리스트로 변환한다.300 301    델타·순위변화를 채우고 final 점수 내림차순으로 정렬한다.302 303    Args:304        baseline_raw: baseline intent_id → score 매핑.305        final_raw: final intent_id → score 매핑.306        engine: 시나리오 엔진.307 308    Returns:309        final 점수 내림차순으로 정렬된 IntentScore 리스트.310    """311    baseline_ranks = _rank_map(baseline_raw)312    final_ranks    = _rank_map(final_raw)313 314    results: list[IntentScore] = []315    for intent in engine.intents():316        iid = intent["id"]317        b = baseline_raw.get(iid, 0.0)318        f = final_raw.get(iid, 0.0)319        br = baseline_ranks.get(iid, 0)320        fr = final_ranks.get(iid, 0)321        results.append(IntentScore(322            intent_id=iid,323            intent_name=intent["name"],324            L1_id=intent["L1_id"],325            L1_name=intent["L1_name"],326            L2_id=intent["L2_id"],327            L2_name=intent["L2_name"],328            inference_type=intent["inference_type"],329            baseline_score=round(b, 4),330            final_score=round(f, 4),331            delta_score=round(f - b, 4),332            baseline_rank=br,333            rank=fr,334            rank_change=br - fr,335        ))336 337    results.sort(key=lambda s: s.final_score, reverse=True)338    return results339 340 341# ── WebSocket 페이로드 헬퍼 ───────────────────────────────────342 343def _softmax(values: list[float], temperature: float) -> list[float]:344    """수치적으로 안정적인 softmax로 raw score를 정규화 확률 분포로 변환한다.345 346    Args:347        values: 정규화할 raw score 리스트.348        temperature: softmax 온도. 작을수록 상위 값에 분포가 집중된다.349 350    Returns:351        합이 1이 되는 정규화 확률 리스트. 입력이 비면 빈 리스트.352    """353    if not values:354        return []355    scaled = [v / temperature for v in values]356    m = max(scaled)357    exps = [math.exp(s - m) for s in scaled]358    total = sum(exps) or 1.0359    return [e / total for e in exps]360 361 362def to_probability_dict(363    scores: list[IntentScore],364    scenario_id: str | None = None,365    temperature: float | None = None,366) -> dict[str, dict[str, float]]:367    """Intent raw score를 softmax 정규화 확률(p)과 baseline 확률(p0)로 변환한다.368 369    raw score 합 분모 정규화는 분포가 너무 평탄하므로 softmax(score / T) 분포를 사용한다.370    T가 작을수록 상위 Intent에 분포가 집중된다.371    T는 scenario_id의 config(L2_inference.calibrator)에서 조회한다(미지정 시 모듈 기본값).372 373    INTENT_UPDATE 페이로드의 `all_probabilities` 필드에 사용된다.374 375    Args:376        scores: 확률로 변환할 IntentScore 리스트.377        scenario_id: 온도 조회에 사용할 시나리오 ID. None이면 모듈 기본값.378        temperature: softmax 온도 직접 지정. None이면 scenario_id로 조회한다.379 380    Returns:381        intent_id → {"p": final 확률, "p0": baseline 확률} 매핑.382    """383    if temperature is None:384        temperature = _resolve_temperature(scenario_id)385    p_vals  = _softmax([s.final_score    for s in scores], temperature)386    p0_vals = _softmax([s.baseline_score for s in scores], temperature)387    return {388        s.intent_id: {389            "p":  round(p_vals[i],  6),390            "p0": round(p0_vals[i], 6),391        }392        for i, s in enumerate(scores)393    }394 395 396def to_topn_with_others(397    scores: list[IntentScore],398    top_n: int = 5,399    scenario_id: str | None = None,400) -> tuple[list[dict], dict]:401    """Top-N + 기타(others) 페이로드를 구성한다.402 403    Args:404        scores: 확률로 변환할 IntentScore 리스트.405        top_n: 상위로 노출할 intent 개수.406        scenario_id: 온도 조회에 사용할 시나리오 ID. None이면 모듈 기본값.407 408    Returns:409        (top_list, others) 튜플.410        top_list: [ {intent_id, ..., probability, baseline_probability, delta_probability} ]411        others: { count, probability, baseline_probability, delta_probability }412    """413    probs = to_probability_dict(scores, scenario_id=scenario_id)414    sorted_scores = sorted(scores, key=lambda s: s.final_score, reverse=True)415 416    top_items: list[dict] = []417    for s in sorted_scores[:top_n]:418        pr = probs[s.intent_id]419        top_items.append({420            "intent_id":            s.intent_id,421            "intent_nm_ko":         s.intent_name,422            "L1_id":                s.L1_id,423            "L1_name":              s.L1_name,424            "L2_id":                s.L2_id,425            "L2_name":              s.L2_name,426            "inference_type":       s.inference_type,427            "rank":                 s.rank,428            "baseline_rank":        s.baseline_rank,429            "rank_change":          s.rank_change,430            "probability":          pr["p"],431            "baseline_probability": pr["p0"],432            "delta_probability":    round(pr["p"] - pr["p0"], 6),433        })434 435    rest = sorted_scores[top_n:]436    others_p  = sum(probs[s.intent_id]["p"]  for s in rest)437    others_p0 = sum(probs[s.intent_id]["p0"] for s in rest)438    others = {439        "count":                len(rest),440        "probability":          round(others_p,  6),441        "baseline_probability": round(others_p0, 6),442        "delta_probability":    round(others_p - others_p0, 6),443    }444    return top_items, others445 446 447# ── Customer Context JSON 생성 ────────────────────────────────448 449def to_customer_context_json(450    session_id: str,451    stage: str,452    scenario_id: str,453    scores: list[IntentScore],454    batch_features: dict[str, Any],455) -> dict[str, Any]:456    """세션·단계·전체 intent 점수를 Customer Context JSON으로 직렬화한다.457 458    저장/조회용 페이로드를 구성한다.459 460    Args:461        session_id: 세션 ID.462        stage: 추론 단계.463        scenario_id: 시나리오 ID.464        scores: 직렬화할 IntentScore 리스트.465        batch_features: 산출된 Batch Feature 매핑.466 467    Returns:468        session/scenario/stage/intents 키를 가진 Customer Context JSON dict.469    """470    intents = []471    for s in scores:472        intents.append({473            "intent_id":         s.intent_id,474            "intent_nm_ko":      s.intent_name,475            "L1":                {"id": s.L1_id, "name": s.L1_name},476            "L2":                {"id": s.L2_id, "name": s.L2_name},477            "baseline_score":    s.baseline_score,478            "final_score":       s.final_score,479            "delta_score":       s.delta_score,480            "baseline_rank":     s.baseline_rank,481            "rank":              s.rank,482            "rank_change":       s.rank_change,483            "inference_type":    s.inference_type,484        })485 486    return {487        "session_id":       session_id,488        "scenario_id":      scenario_id,489        "stage":            stage,490        "intents":          intents,491    }492