| from __future__ import annotations |
| """ |
| 파생 변수(Index/Score) 산출 근거 trace — 시나리오 무관. |
| |
| L1 batch_builder 의 선언형 step formula 와 실제 입력값을 풀어, |
| 각 최종 파생 변수가 "어떤 입력으로 어떻게 계산됐는지"를 사람이 읽을 수 있는 |
| 구조로 반환한다. (프론트 분석 오버레이의 변수 클릭 → 산식 팝오버용) |
| |
| 반환: { 파생변수명: { |
| "value": float, # 최종 값 |
| "clamp": [lo,hi] | None, # 범위 제한(있으면) |
| "kind": "passthrough" | "weighted_sum", |
| "terms": [ { # 기여 항 |
| "ref": str, # 입력 변수명(base 또는 노출 Index명) |
| "ref_value": float, # 그 입력의 현재 값 |
| "weight": float | None, # 가중치(선형 [w,0] 항일 때) |
| "contribution": float, # 이 항이 결과에 더한 값 |
| }, ... ], |
| } } |
| """ |
| from typing import Any |
|
|
| from core.engines import config |
| from core.engines.extract import survey_base |
| from core.engines.formula import eval_formula, _load_py |
|
|
|
|
| def build_feature_trace(scenario_id: str, answers: dict[str, str]) -> dict[str, dict]: |
| """파생 변수의 산출 근거 trace를 구성한다. |
| |
| L1 batch_builder의 선언형 formula와 실제 입력값을 풀어, 각 최종 파생 변수가 |
| 어떤 입력으로 어떻게 계산됐는지를 사람이 읽을 수 있는 구조로 반환한다. |
| |
| Args: |
| scenario_id: 시나리오 ID. |
| answers: 질문 ID → 선택 응답 코드 매핑. |
| |
| Returns: |
| 파생변수명 → {"value", "clamp", "kind", "terms"} 매핑. step이 없으면 빈 dict. |
| """ |
| survey = config.get_survey(scenario_id) |
| spec = config.get_batch_builder(scenario_id) |
| steps = spec.get("steps", []) |
| if not steps: |
| return {} |
|
|
| |
| base_answer: dict[str, str] = {} |
| for q in survey.get("questions", []): |
| code = answers.get(q["id"]) |
| if code is None: |
| continue |
| opt = next((o for o in q.get("options", []) if o.get("code") == code), None) |
| if not opt: |
| continue |
| for fname in (opt.get("features") or {}): |
| base_answer[fname] = opt.get("label") |
|
|
| |
| feats: dict[str, Any] = dict(survey_base(survey, answers)) |
| for k, v in spec.get("defaults", {}).items(): |
| feats.setdefault(k, v) |
| if "pre_hook" in spec: |
| feats.update(_load_py(spec["pre_hook"])(feats)) |
|
|
| inter_names: set[str] = set() |
| step_formula: dict[str, Any] = {} |
| label_map: dict[str, str] = {} |
| for st in steps: |
| val = eval_formula(st["formula"], feats) |
| if "round" in st: |
| val = round(val, st["round"]) |
| feats[st["name"]] = val |
| step_formula[st["name"]] = st["formula"] |
| if st.get("label"): |
| label_map[st["name"]] = st["label"] |
| if st.get("intermediate"): |
| inter_names.add(st["name"]) |
|
|
| |
| inter_to_final: dict[str, str] = {} |
| for st in steps: |
| f = st["formula"] |
| if (not st.get("intermediate") and isinstance(f, dict) and f.get("feat") in inter_names |
| and f.get("linear") == [1, 0] and "div" not in f and "mul" not in f): |
| inter_to_final[f["feat"]] = st["name"] |
|
|
| def _terms(formula): |
| """formula의 기여 항 리스트를 추출한다. |
| |
| Args: |
| formula: 평가 대상 formula 노드. |
| |
| Returns: |
| 기여 항 리스트. 상수 등 비-dict formula는 빈 리스트, 단일 feat 노드는 그 노드만. |
| """ |
| if not isinstance(formula, dict): |
| return [] |
| if "terms" in formula: |
| return formula["terms"] |
| return [formula] |
|
|
| def _cond_feat(c): |
| """조건(cond)이 참조하는 입력 feat를 찾는다. |
| |
| 단일 조건과 복합 조건(all·any·not)을 모두 탐색한다. |
| |
| Args: |
| c: 조건 노드. |
| |
| Returns: |
| 참조 입력 feat명. 없으면 None. |
| """ |
| if not isinstance(c, dict): |
| return None |
| if c.get("feat") is not None: |
| return c["feat"] |
| for key in ("all", "any"): |
| for sub in (c.get(key) or []): |
| r = _cond_feat(sub) |
| if r: |
| return r |
| if "not" in c: |
| return _cond_feat(c["not"]) |
| return None |
|
|
| def _feat_of(t): |
| """항이 참조하는 입력 feat를 찾는다. |
| |
| 평면 {feat}, 중첩 {clamp,value:{feat}}, 조건부 {if/switch}를 모두 인식한다. |
| |
| Args: |
| t: 기여 항 노드. |
| |
| Returns: |
| 참조 입력 feat명. 없으면 None. |
| """ |
| if not isinstance(t, dict): |
| return None |
| if t.get("feat") is not None: |
| return t["feat"] |
| v = t.get("value") |
| if isinstance(v, dict) and v.get("feat") is not None: |
| return v["feat"] |
| if isinstance(t.get("if"), dict): |
| return _cond_feat(t["if"]) |
| if isinstance(t.get("switch"), list) and t["switch"]: |
| return _cond_feat(t["switch"][0].get("if", {})) |
| return None |
|
|
| def _weight(term: dict): |
| """기여 항의 가중치를 추출한다. |
| |
| 선형 [w, 0] 항의 계수 또는 clamp/value 래퍼 항의 mul을 가중치로 본다. |
| |
| Args: |
| term: 기여 항 노드. |
| |
| Returns: |
| 가중치 값. 가중치로 해석할 수 없으면 None. |
| """ |
| if not isinstance(term, dict): |
| return None |
| lin = term.get("linear") |
| if lin and lin[1] == 0 and "div" not in term and "mul" not in term: |
| return lin[0] |
| if isinstance(term.get("mul"), (int, float)): |
| return term["mul"] |
| return None |
|
|
| def _num(v): |
| """수치값을 소수점 2자리로 반올림한다. |
| |
| Args: |
| v: 변환할 값. |
| |
| Returns: |
| 수치(bool 제외)는 반올림한 float, 그 외는 원본 그대로. |
| """ |
| return round(float(v), 2) if isinstance(v, (int, float)) and not isinstance(v, bool) else v |
|
|
| trace: dict[str, dict] = {} |
| for st in steps: |
| if st.get("intermediate"): |
| continue |
| name = st["name"] |
| f = st["formula"] |
| |
| eff = f |
| kind = "weighted_sum" |
| if isinstance(f, dict) and f.get("feat") in inter_names and f.get("linear") == [1, 0]: |
| eff = step_formula[f["feat"]] |
| kind = "passthrough" |
| clamp = eff["clamp"] if isinstance(eff, dict) and "clamp" in eff else None |
| |
| mul_o = eff.get("mul", 1) if isinstance(eff, dict) else 1 |
| div_o = eff.get("div", 1) if isinstance(eff, dict) else 1 |
| factor = (mul_o or 1) / (div_o or 1) |
|
|
| terms = [] |
| for t in _terms(eff): |
| ref = _feat_of(t) |
| try: |
| contribution = round(eval_formula(t, feats) * factor, 2) |
| except Exception: |
| contribution = None |
| if ref is None: |
| |
| is_cond = isinstance(t, dict) and ("if" in t or "switch" in t) |
| terms.append({ |
| "ref": "조건부 항" if is_cond else "기본 점수", |
| "ref_value": None, "ref_answer": None, |
| "weight": None, "contribution": contribution, |
| }) |
| continue |
| terms.append({ |
| "ref": label_map.get(ref) or inter_to_final.get(ref, ref), |
| "ref_value": _num(feats.get(ref, 0.0)), |
| "ref_answer": base_answer.get(ref), |
| "weight": _weight(t), |
| "contribution": contribution, |
| }) |
| trace[name] = { |
| "value": _num(feats[name]), |
| "clamp": clamp, |
| "kind": kind, |
| "terms": terms, |
| } |
| return trace |
|
|