| 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 |
|
|
| |
| |
| 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__) |
|
|
| |
| |
| |
| |
| ACTION_SIGNAL_SCALE = 0.28 |
| ACTION_SIGNAL_CAP = 0.55 |
| ACTION_SIGNAL_DECAY = 0.6 |
|
|
|
|
| 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 |
| final_score: float |
| delta_score: float |
| baseline_rank: int |
| rank: int |
| rank_change: int |
|
|
|
|
| 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_features = { |
| **batch_features, |
| **engine.empty_pattern_features(), |
| **engine.empty_event_features(), |
| } |
| baseline_raw = _score_all(baseline_features, engine) |
|
|
| |
| 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) |
|
|
| |
| scale, cap, decay = _resolve_action_signal(scenario_id) |
| boost_mode = _resolve_boost_mode(scenario_id) |
| 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": |
| |
| final_raw[iid] = final_raw[iid] + boost * (1.0 - final_raw[iid]) |
| else: |
| final_raw[iid] = min(final_raw[iid] + boost, 0.97) |
|
|
| |
| |
| 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 |
|
|
|
|
| |
|
|
| 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 |
|
|
|
|
| |
|
|
| 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, |
| } |
|
|