| from __future__ import annotations |
| """ |
| Intent ์ถ๋ก ์ด์ (reasoning) ์ถ์ถ โ ์๋๋ฆฌ์ค ๋ฌด๊ด. |
| |
| - Rule : L2 rule ์ ์ธํ spec์ top-level ํญ(terms)๋ณ ๊ธฐ์ฌ๋๋ฅผ ๋ถํด. |
| - Model : sklearn(StandardScaler+LogisticRegression) ์ ํ์ฑ์ ์ด์ฉํด |
| feature๋ณ ๊ธฐ์ฌ๋ = coef ร ํ์คํ๊ฐ ์ผ๋ก ๋ถํด (models.sklearn_model.explain). |
| - ํ๋ : rank_change>0๋ฉด "์ต๊ทผ ํ๋์ผ๋ก ์์น" ๋
ธํธ. |
| |
| ๋ฐํ ํ์(intent๋ณ): |
| {"type": "Rule"|"Model", |
| "factors": [{"label": str, "contribution": float, "direction": "up"|"down"}, ...]} # |๊ธฐ์ฌ| ์์ |
| """ |
| from typing import Any |
|
|
| from core.engines import config |
| from core.engines.formula import eval_formula |
|
|
|
|
| def _node_label(node: Any) -> str: |
| """rule ํญ ๋
ธ๋๋ฅผ ์ฌ๋์ด ์ฝ์ ๋ผ๋ฒจ๋ก ๋ณํํ๋ค. |
| |
| Args: |
| node: rule ํญ ๋
ธ๋. |
| |
| Returns: |
| ๋ผ๋ฒจ ๋ฌธ์์ด. |
| """ |
| if isinstance(node, (int, float, bool)): |
| return "๊ธฐ๋ณธ ์ ์" |
| if not isinstance(node, dict): |
| return str(node)[:24] |
| if "feat" in node: |
| return str(node["feat"]) |
| if "boost" in node: |
| return f"ํ๋: {node['boost'].get('feat', '')}" |
| if "if" in node: |
| cond = node["if"] |
| feat = cond.get("feat") or (cond.get("all") or cond.get("any") or [{}])[0].get("feat") |
| return f"์กฐ๊ฑด: {feat}" if feat else "์กฐ๊ฑด" |
| if "switch" in node: |
| c0 = (node["switch"] or [{}])[0].get("if", {}) |
| return f"์กฐ๊ฑด: {c0.get('feat', '')}" |
| if "clamp" in node and "value" in node: |
| return _node_label(node["value"]) |
| if "terms" in node: |
| for t in node["terms"]: |
| if not isinstance(t, (int, float, bool)): |
| return _node_label(t) |
| return "๋ณตํฉ ํญ" |
| return "ํญ" |
|
|
|
|
| def _node_feat(node: Any) -> str | None: |
| """rule ํญ์ด ์ฐธ์กฐํ๋ feature ํค๋ฅผ ์ฐพ๋๋ค (ํ์ฌ๊ฐ ์กฐํ์ฉ). |
| |
| Args: |
| node: rule ํญ ๋
ธ๋. |
| |
| Returns: |
| ์ฐธ์กฐ feature ํค. ์์ผ๋ฉด None. |
| """ |
| if not isinstance(node, dict): |
| return None |
| if "feat" in node: |
| return node["feat"] |
| if "boost" in node: |
| return node["boost"].get("feat") |
| if "if" in node: |
| c = node["if"] |
| return c.get("feat") or (c.get("all") or c.get("any") or [{}])[0].get("feat") |
| if "switch" in node: |
| return (node["switch"] or [{}])[0].get("if", {}).get("feat") |
| if "clamp" in node and "value" in node: |
| return _node_feat(node["value"]) |
| if "terms" in node: |
| for t in node["terms"]: |
| f = _node_feat(t) |
| if f: |
| return f |
| return None |
|
|
|
|
| |
| VALUE_LABELS = { |
| "contract_status": {1: "์ฝ์ ์์", 2: "์ฝ์ ์์ฌ 6๊ฐ์+", 3: "์ฝ์ ๋ง๋ฃ ์๋ฐ"}, |
| "๊ณ ๊ฐ ๋ฑ๊ธ": {"Gold": "๊ณจ๋", "Silver": "์ค๋ฒ", "Bronze": "๋ธ๋ก ์ฆ", "VIP": "VIP", "Green": "๊ทธ๋ฆฐ"}, |
| } |
|
|
|
|
| def _fmt_value(feat: str | None, v: Any) -> str | None: |
| """feature ํ์ฌ๊ฐ์ ํ์ ๋ฌธ์์ด๋ก ๋ณํํ๋ค. |
| |
| ์ฝ๋๊ฐ(contract_status ๋ฑ)์ ์ค์ ์๋ฏธ๋ก ๋ณํํ๋ค. |
| |
| Args: |
| feat: feature ํค. VALUE_LABELS ์กฐํ์ ์ฌ์ฉ๋๋ค. |
| v: feature ํ์ฌ๊ฐ. |
| |
| Returns: |
| ํ์ ๋ฌธ์์ด. v๊ฐ None์ด๋ฉด None. |
| """ |
| if v is None: |
| return None |
| if feat in VALUE_LABELS: |
| key = int(v) if isinstance(v, (int, float)) and float(v).is_integer() else v |
| if key in VALUE_LABELS[feat]: |
| return VALUE_LABELS[feat][key] |
| if isinstance(v, bool): |
| return "์" if v else "์๋์ค" |
| if isinstance(v, (int, float)): |
| return f"{v:.2f}".rstrip("0").rstrip(".") |
| return str(v) |
|
|
|
|
| def explain_rule(scenario_id: str, intent_id: str, features: dict, top: int = 3) -> list[dict]: |
| """rule spec์ top-level ํญ๋ณ ๊ธฐ์ฌ๋๋ฅผ ๋ถํดํ๋ค. |
| |
| |๊ธฐ์ฌ| ์์ top๊ฐ๋ฅผ ๋ฐํํ๋ฉฐ, ์์(๊ธฐ๋ณธ ์ ์)๋ ์ ์ธํ๊ณ ํ๊ธ ๋ผ๋ฒจยท์ค์ ๊ฐ์ ํฌํจํ๋ค. |
| |
| Args: |
| scenario_id: ์๋๋ฆฌ์ค ID. |
| intent_id: ๋์ intent ID. |
| features: ์ถ๋ก ์ ์ฐ์ธ feature ๋งคํ. |
| top: ๋ฐํํ ์์ ํญ ๊ฐ์. |
| |
| Returns: |
| |๊ธฐ์ฌ| ๋ด๋ฆผ์ฐจ์ ์์ top๊ฐ์ factor dict ๋ฆฌ์คํธ. spec์ด ์์ผ๋ฉด ๋น ๋ฆฌ์คํธ. |
| """ |
| spec = config.get_rule_spec(scenario_id).get(intent_id) |
| if spec is None: |
| return [] |
| terms = spec["terms"] if isinstance(spec, dict) and "terms" in spec else [spec] |
| out = [] |
| for t in terms: |
| if isinstance(t, (int, float, bool)): |
| continue |
| val = float(eval_formula(t, features)) |
| if abs(val) < 1e-9: |
| continue |
| feat = _node_feat(t) |
| out.append({"label": _label_ko(_node_label(t)), "contribution": round(val, 4), |
| "direction": "up" if val >= 0 else "down", |
| "value": _fmt_value(feat, features.get(feat)) if feat else None}) |
| return sorted(out, key=lambda o: -abs(o["contribution"]))[:top] |
|
|
|
|
| def explain_intent(engine, intent_id: str, features: dict, inference_type: str, top: int = 3) -> dict: |
| """intent 1๊ฐ์ ์ถ๋ก ์ด์ ๋ฅผ ๋ถํดํ๋ค. |
| |
| inference_type์ ๋ฐ๋ผ rule/model๋ก ๋ถํดํ๋ฉฐ, ๋ผ๋ฒจ์ ํ๊ธํํ๊ณ ์ฝ๋๊ฐ์ ์ค์ ๊ฐ์ผ๋ก ๋ณํํ๋ค. |
| |
| Args: |
| engine: ์๋๋ฆฌ์ค ์์ง. |
| intent_id: ๋์ intent ID. |
| features: ์ถ๋ก ์ ์ฐ์ธ feature ๋งคํ. |
| inference_type: "Model" ๋๋ "Rule". |
| top: ๋ฐํํ ์์ factor ๊ฐ์. |
| |
| Returns: |
| {"type", "factors"} ํค๋ฅผ ๊ฐ์ง reasoning dict. |
| """ |
| if inference_type == "Model": |
| factors = engine.explain_model(intent_id, features, top=top) |
| for f in factors: |
| feat = f.get("label", "") |
| f["value"] = _fmt_value(feat, f.get("value")) |
| f["label"] = _label_ko(feat) |
| return {"type": "Model", "factors": factors} |
| return {"type": "Rule", "factors": explain_rule(engine.scenario_id, intent_id, features, top=top)} |
|
|
|
|
| |
| FEATURE_LABELS = { |
| "์ดํ ์ํ Score": "์ดํ ์ํ๋", "Churn Risk Index": "์ดํ ์ํ๋", |
| "์๊ธ ๋ฏผ๊ฐ๋ Index": "์๊ธ ๋ฏผ๊ฐ๋", "๋น์ฉ ๋ถ๋ด๋": "๋น์ฉ ๋ถ๋ด", "์๊ธ์ ์์ ์ก": "์๊ธ์ ๊ธ์ก", |
| "์ฝ์ ์งํ๋ฅ ": "์ฝ์ ์งํ ์ ๋", "contract_status": "์ฝ์ ์ํ", |
| "๋ฐ์ดํฐ ์ฌ์ฉ ์ฆ๊ฐ๋ฅ ": "๋ฐ์ดํฐ ์ฌ์ฉ ์ฆ๊ฐ์ธ", "๋ฐ์ดํฐ ์ฌ์ฉ๋ฅ ": "๋ฐ์ดํฐ ์ฌ์ฉ๋", |
| "์
์
์ ํฉ๋ Score": "์์ ์ํ ์ ํฉ๋", "์ฌ์ฉ ๊ฐ๋ Index": "์ฌ์ฉ ๊ฐ๋", |
| "๋จ๋ง ๊ต์ฒด ์ํฅ Score": "๋จ๋ง ๊ต์ฒด ์ํฅ", "๋ฉค๋ฒ์ญ ์ฃผ๊ฐ ์ฌ์ฉ ํ์": "๋ฉค๋ฒ์ญ ์ฌ์ฉ ๋น๋", |
| "๊ณ ๊ฐ ๊ฐ์น Index": "๊ณ ๊ฐ ๊ฐ์น", "๋ฉค๋ฒ์ญ ํ์ฉ๋": "๋ฉค๋ฒ์ญ ํ์ฉ๋", "๊ณ ๊ฐ ๋ฑ๊ธ": "๊ณ ๊ฐ ๋ฑ๊ธ", |
| "๊ฐ์กฑ ํ์ ์": "๊ฐ์กฑ ํ์ ์", "non_mobile_cost_gap": "๊ฒฐํฉ ๋น์ฉ ๊ฒฉ์ฐจ", |
| "mnp_benefit_check": "๋ฒํธ์ด๋ ํํ ์กฐํ", "์์ฝ๊ธ ์กฐํ ํ๋": "์์ฝ๊ธ ํ์ด์ง ์กฐํ", |
| "ํด์ง ํ์ด์ง ์ง์
": "ํด์ง ํ์ด์ง ๋ฐฉ๋ฌธ", "churn_page_view_count": "ํด์ง ๊ด๋ จ ํ์ด์ง ์กฐํ", |
| "dissatisfaction_factor": "์๋น์ค ๋ถ๋ง ์์ธ", "support_entry_count_5m": "์๋ด ์ง์
", |
| "quality_action_count": "ํ์ง ์ง๋จ ์คํ", "benefit_explore_count": "ํํ ํ์", |
| "billing_page_view_count": "์๊ธ ์กฐํ", "product_explore_count": "์ํ ํ์", |
| "social_contact": "์ฌํ์ ์ ์ด", "weekend_out": "์ฃผ๋ง ์ธ์ถ", "night_phone_usage": "์ผ๊ฐ ์ค๋งํธํฐ ์ฌ์ฉ", |
| "move_pattern": "ํด๊ทผ ํ ์ด๋", "Isolation Tendency Index": "๊ณ ๋ฆฝ ์ฑํฅ", |
| "Sleep Disturbance Index": "์๋ฉด ๋ฐฉํด", "Burnout Deep Score": "๋ฒ์์ ์ ๋", |
| "Recovery Motivation Score": "ํ๋ณต ๋๊ธฐ", "Fatigue Load Index": "ํผ๋ก ๋์ ", |
| "Digital Escape Score": "๋์งํธ ๋ํผ ์ฑํฅ", "Retention Value Index": "์ ์ง ๊ฐ์น", |
| "Benefit Engagement Index": "ํํ ์ฐธ์ฌ๋", |
| |
| "Bundle Opportunity Index": "๊ฒฐํฉ ๊ธฐํ ์ง์", "Home Service Expansion Index": "ํ ์๋น์ค ํ์ฅ ์ง์", |
| "Benefit Optimization Index": "ํํ ์ต์ ํ ์ง์", "Benefit Optimization Score": "ํํ ์ต์ ํ ์ ์", |
| "Service Expansion Score": "์๋น์ค ํ์ฅ ์ ์", "Acquisition Score": "์ ๊ท ํ๋ ์ ์", |
| "Retention Readiness Index": "์ ์ง ์ค๋น ์ง์", "Retention Value Index": "์ ์ง ๊ฐ์น ์ง์", |
| "Retention Value Score": "์ ์ง ๊ฐ์น ์ ์", "Retention Score": "์ ์ง ์ ์", |
| "Churn Defense Score": "์ดํ ๋ฐฉ์ด ์ ์", |
| |
| "churn_action_count_5m": "์ต๊ทผ ํด์ง ๊ด๋ จ ํ๋", "comparison_action_count_5m": "์ต๊ทผ ๋น๊ต ํ๋", |
| "decision_action_count_5m": "์ต๊ทผ ๊ฐ์
ยท๊ฒฐ์ ํ๋", "entity_focus_ratio_5m": "ํน์ ๋ฉ๋ด ์ง์ค๋", |
| "WiFi ์ง๋จ ์คํ": "WiFi ์ง๋จ ์คํ", "์๋ ์ธก์ ์คํ": "์๋ ์ธก์ ์คํ", |
| "์ฅ์ ํ์ด์ง ์ฒด๋ฅ": "์ฅ์ ํ์ด์ง ์ฒด๋ฅ", "ํ ์ธ ํ์ด์ง ์ฒด๋ฅ": "ํ ์ธ ํ์ด์ง ์กฐํ", |
| "๊ฐ์กฑ ๊ฒฐํฉ ๊ด๋ จ ํ๋": "๊ฐ์กฑ ๊ฒฐํฉ ํ์ด์ง ์กฐํ", |
| |
| "benefit_utilization": "ํํ ํ์ฉ๋", "content_view_mode": "์์ ์๋น ๊ฐ๋", |
| "family_line_count": "๊ฐ์กฑ ํ์ ์", "household_change": "๊ฐ๊ตฌ ๋ณํ", "offwork_time": "ํด๊ทผ ์๊ฐ", |
| "overtime_freq": "์ผ๊ทผ ๋น๋", "plan_tier": "์๊ธ์ ๋ฑ๊ธ", "plan_bill_level": "์๊ธ์ ๊ธ์ก๋", |
| "monthly_bill_level": "์ ์๊ธ ์์ค", "service_coverage_ratio": "์๋น์ค ์ปค๋ฒ๋ฆฌ์ง ๋น์จ", |
| "tenure_group": "๊ฐ์
๊ธฐ๊ฐ๋", "age_group": "์ฐ๋ น๋", "subscribed_service_count": "๊ฐ์
๋ถ๊ฐ์๋น์ค ์", |
| } |
|
|
|
|
| def _label_ko(raw: str) -> str: |
| """factor ๋ผ๋ฒจ์ ์์ฐ์ค๋ฌ์ด ํ๊ธ๋ก ๋ณํํ๋ค. |
| |
| '์กฐ๊ฑด:/ํ๋:' ์ ๋๋ฅผ ์ ๊ฑฐํ ๋ค ๋งคํํ๋ฉฐ, ๋ฏธ๋ฑ๋ก ๋ผ๋ฒจ์ Index/Score ์ ๋ฏธ์ฌ๋ฅผ ์ ๋ฆฌํ๋ค. |
| |
| Args: |
| raw: ์๋ณธ factor ๋ผ๋ฒจ. |
| |
| Returns: |
| ํ๊ธํ๋ ๋ผ๋ฒจ. |
| """ |
| s = raw.replace("์กฐ๊ฑด: ", "").replace("ํ๋: ", "").strip() |
| if s in FEATURE_LABELS: |
| return FEATURE_LABELS[s] |
| return s.replace(" Index", "").replace(" Score", "").strip() or s |
|
|
|
|
| def _situation_text(intent_name: str, r: dict) -> str: |
| """์๋ด์ฌ ์ฝ์ [์ํฉ]์ฉ ์ถ๋ก ์ด์ ๋ฌธ์ฅ์ ๋ง๋ ๋ค. |
| |
| ์ด ๊ณ ๊ฐ ํน์ฑ์ผ๋ก intent๊ฐ ์ถ๋ก ๋ ์ด์ ๋ฅผ ์์ฐ์ด ํ ๋ฌธ์ฅ์ผ๋ก ํํํ๋ค. |
| |
| Args: |
| intent_name: intent ํ๊ธ๋ช
. |
| r: reasoning dict (factors ํฌํจ). |
| |
| Returns: |
| ์์ฐ์ด ํ ๋ฌธ์ฅ. |
| """ |
| facts = [f for f in r.get("factors", []) if f.get("label") != "๊ธฐ๋ณธ ์ ์"] |
| facts = [f for f in facts if f.get("direction") == "up"] or facts |
| labels = [] |
| for f in facts[:3]: |
| lab = _label_ko(f["label"]) |
| if lab and lab not in labels: |
| labels.append(lab) |
| if not labels: |
| head = f"๊ณ ๊ฐ ์๋ต์ ์ข
ํฉํด '{intent_name}' ์๋๊ฐ ์ถ๋ก ๋์์ต๋๋ค" |
| else: |
| head = f"์ด ๊ณ ๊ฐ์ {' ยท '.join(labels)} ์ธก๋ฉด์ด ๋๋๋ฌ์ ธ '{intent_name}' ์๋๊ฐ ์ถ๋ก ๋์์ต๋๋ค" |
| return head + "." |
|
|
|
|
| def attach_reasoning(engine, features: dict, top_items: list[dict], top: int = 3) -> None: |
| """์๋น top_items ๊ฐ ํญ๋ชฉ์ reasoning์ ์ฒจ๋ถํ๋ค (in-place). |
| |
| reasoning.situation_text๋ ์๋ด์ฌ ์ฝ์ [์ํฉ]์ฉ ๋์ ์ถ๋ก ์ด์ ๋ฌธ์ฅ์ด๋ค. |
| |
| Args: |
| engine: ์๋๋ฆฌ์ค ์์ง. |
| features: ์ถ๋ก ์ ์ฐ์ธ ๊ฒฐํฉ feature(batch+pattern+event) ๋งคํ. |
| top_items: reasoning์ ์ฒจ๋ถํ ์๋น ํญ๋ชฉ ๋ฆฌ์คํธ. |
| top: ํญ๋ชฉ๋น ๋ถํดํ ์์ factor ๊ฐ์. |
| """ |
| for it in top_items: |
| r = explain_intent(engine, it["intent_id"], features, it.get("inference_type", "Rule"), top=top) |
| rc = it.get("rank_change", 0) |
| if rc and rc > 0: |
| r["behavior_note"] = f"์ต๊ทผ ํ๋์ผ๋ก {rc}์ ์์น" |
| elif rc and rc < 0: |
| r["behavior_note"] = f"์ต๊ทผ ํ๋์ผ๋ก {abs(rc)}์ ํ๋ฝ" |
| r["situation_text"] = _situation_text(it.get("intent_nm_ko", it["intent_id"]), r) |
| it["reasoning"] = r |
|
|