hpce-dev / core /explain.py
์ด๋™ํ˜„
[DOCS] ๋ฐฑ์—”๋“œ ์ „๋ฐ˜ docstring ๋ณด๊ฐ• (Google ์Šคํƒ€์ผ Args/Returns/Raises)
58debbc
Raw
History Blame Contribute Delete
12.6 kB
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: # ํ–‰๋™(์œˆ๋„์šฐ) feature
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: # ์ค‘์ฒฉ ํ•ญ โ†’ ์ฒซ feature ํ•ญ์œผ๋กœ ๋Œ€ํ‘œ
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
# ์ฝ”๋“œ๊ฐ’ feature โ†’ ์‹ค์ œ ์˜๋ฏธ (์ฝ”๋“œ๊ฐ€ ์•„๋‹Œ ์‹ค์ œ๊ฐ’ ํ‘œ์‹œ)
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: # ๋ชจ๋ธ feature๋ช… โ†’ ํ•œ๊ธ€, ์ฝ”๋“œ๊ฐ’ โ†’ ์‹ค์ œ๊ฐ’
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 ๋‚ด๋ถ€๋ช… โ†’ ์ƒ๋‹ด์‚ฌ์šฉ ํ•œ๊ธ€ ๋ผ๋ฒจ
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 ๋ชจ๋ธ Index/Score
"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": "์ดํƒˆ ๋ฐฉ์–ด ์ ์ˆ˜",
# ํ–‰๋™/์ด๋ฒคํŠธ ์œˆ๋„์šฐ feature (์ƒ๋‹ด์‚ฌ [์ƒํ™ฉ]์— ๋“ฑ์žฅ)
"churn_action_count_5m": "์ตœ๊ทผ ํ•ด์ง€ ๊ด€๋ จ ํ–‰๋™", "comparison_action_count_5m": "์ตœ๊ทผ ๋น„๊ต ํ–‰๋™",
"decision_action_count_5m": "์ตœ๊ทผ ๊ฐ€์ž…ยท๊ฒฐ์ • ํ–‰๋™", "entity_focus_ratio_5m": "ํŠน์ • ๋ฉ”๋‰ด ์ง‘์ค‘๋„",
"WiFi ์ง„๋‹จ ์‹คํ–‰": "WiFi ์ง„๋‹จ ์‹คํ–‰", "์†๋„ ์ธก์ • ์‹คํ–‰": "์†๋„ ์ธก์ • ์‹คํ–‰",
"์žฅ์•  ํŽ˜์ด์ง€ ์ฒด๋ฅ˜": "์žฅ์•  ํŽ˜์ด์ง€ ์ฒด๋ฅ˜", "ํ• ์ธ ํŽ˜์ด์ง€ ์ฒด๋ฅ˜": "ํ• ์ธ ํŽ˜์ด์ง€ ์กฐํšŒ",
"๊ฐ€์กฑ ๊ฒฐํ•ฉ ๊ด€๋ จ ํ–‰๋™": "๊ฐ€์กฑ ๊ฒฐํ•ฉ ํŽ˜์ด์ง€ ์กฐํšŒ",
# ํ”„๋กœํ•„ snake_case
"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 + "." # ์ˆœ์œ„ ๋ณ€๋™(behavior_note)์€ ์ƒ๋‹ด์‚ฌ [์ƒํ™ฉ]์—์„œ ์ œ์™ธ
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