| """Reglas can贸nicas de decisi贸n para mantener consistencia entre UI, LLM, gr谩ficos y PDF. |
| |
| Este m贸dulo no reemplaza los modelos. Su funci贸n es integrar sus salidas para |
| que el dashboard y el reporte ejecutivo no se contradigan. |
| """ |
| from __future__ import annotations |
|
|
| from typing import Any, Dict |
|
|
|
|
| def _sf(value: Any, default: float = 0.0) -> float: |
| try: |
| if value in (None, ""): |
| return default |
| return float(value) |
| except Exception: |
| return default |
|
|
|
|
| def _norm_text(value: Any) -> str: |
| return str(value or "").strip().lower() |
|
|
|
|
| def probability(result: Dict[str, Any] | None = None, final_rec: Dict[str, Any] | None = None, paid_xgb: Dict[str, Any] | None = None) -> float: |
| result = result or {} |
| final_rec = final_rec or {} |
| paid_xgb = paid_xgb or {} |
| return max(0.0, min(1.0, _sf( |
| result.get("probabilidad_rendimiento", final_rec.get("probabilidad_rendimiento", paid_xgb.get("logistic_probability", 0.0))) |
| ))) |
|
|
|
|
| def visual_score_0_100(visual: Dict[str, Any] | None = None, result: Dict[str, Any] | None = None) -> float | None: |
| visual = visual or (result or {}).get("analisis_visual", {}) or {} |
| raw = visual.get("composition_score", visual.get("visual_score", visual.get("score", None))) |
| if raw in (None, ""): |
| return None |
| score = _sf(raw, 0.0) |
| return score * 100.0 if score <= 1.5 else score |
|
|
|
|
| def paid_score_0_100(paid_xgb: Dict[str, Any] | None = None, result: Dict[str, Any] | None = None) -> float | None: |
| paid_xgb = paid_xgb or (result or {}).get("xgboost_pauta", {}) or ((result or {}).get("metricas", {}) or {}).get("xgboost_pauta", {}) or {} |
| raw = paid_xgb.get("predicted_paid_performance_score", paid_xgb.get("rules_paid_performance_score", paid_xgb.get("raw_xgboost_paid_performance_score", None))) |
| if raw in (None, ""): |
| return None |
| score = _sf(raw, 0.0) |
| return score * 100.0 if score <= 1.5 else score |
|
|
|
|
| def visual_interpretation(score: float | None) -> Dict[str, str]: |
| if score is None: |
| return { |
| "label": "sin video", |
| "summary": "No hay score visual suficiente; el an谩lisis se apoya en m茅tricas, texto y pol铆ticas.", |
| "severity": "neutral", |
| } |
| if score <= 59: |
| return { |
| "label": "cr铆tico", |
| "summary": "Score visual por debajo de 60: no se recomienda invertir; requiere atenci贸n inmediata en composici贸n, legibilidad o montaje.", |
| "severity": "danger", |
| } |
| if score <= 70: |
| return { |
| "label": "regular", |
| "summary": "Score visual entre 60 y 70: es viable como prueba controlada, pero requiere ajustes de posproducci贸n y revisi贸n humana.", |
| "severity": "warning", |
| } |
| if score <= 80: |
| return { |
| "label": "bueno", |
| "summary": "Score visual entre 71 y 80: composici贸n buena; puede mejorar retenci贸n y comprensi贸n del mensaje.", |
| "severity": "success", |
| } |
| return { |
| "label": "muy bien compuesto", |
| "summary": "Score visual entre 81 y 100: pieza muy bien compuesta y consistente para pauta controlada.", |
| "severity": "success", |
| } |
|
|
|
|
| def classify_investment_decision( |
| *, |
| result: Dict[str, Any] | None = None, |
| final_rec: Dict[str, Any] | None = None, |
| paid_xgb: Dict[str, Any] | None = None, |
| policy: Dict[str, Any] | None = None, |
| visual: Dict[str, Any] | None = None, |
| ) -> Dict[str, Any]: |
| """Devuelve una decisi贸n 煤nica para UI/PDF/LLM. |
| |
| Estados can贸nicos: |
| - invest: invertir/pautar |
| - adjust: ajustar antes de invertir |
| - no: no invertir |
| - review: revisi贸n humana inmediata |
| """ |
| result = result or {} |
| final_rec = final_rec or result |
| paid_xgb = paid_xgb or result.get("xgboost_pauta") or ((result.get("metricas", {}) or {}).get("xgboost_pauta")) or {} |
| policy = policy or result.get("analisis_politicas") or {} |
| visual = visual or result.get("analisis_visual") or {} |
|
|
| prob = probability(result, final_rec, paid_xgb) |
| vscore = visual_score_0_100(visual, result) |
| pscore = paid_score_0_100(paid_xgb, result) |
| policy_level = _norm_text(result.get("policy_risk_level") or final_rec.get("policy_risk_level") or policy.get("policy_risk_level") or "bajo") |
| policy_cap = policy.get("probability_cap") |
| sensitive = bool(policy.get("policy_forced_probability")) or policy_level in {"alto", "revisi贸n humana", "revision humana", "high"} |
| gate_passed = bool(paid_xgb.get("gate_passed", paid_xgb.get("eligible_for_paid_xgboost", False))) |
| eligible = bool(paid_xgb.get("eligible_for_paid_xgboost", False)) |
| cpm = _sf(paid_xgb.get("predicted_cpm") or result.get("cpm_estimado") or 0.0) |
|
|
| |
| if policy_level in {"revisi贸n humana", "revision humana"} or (sensitive and prob <= 0.20) or (_sf(policy_cap, 1.0) <= 0.20): |
| return { |
| "key": "review", |
| "label": "REVISI脫N HUMANA INMEDIATA", |
| "headline": "RECOMENDACI脫N: REVISI脫N HUMANA INMEDIATA", |
| "ui_label": "馃煟 Revisi贸n humana inmediata: contenido sensible o riesgo de pol铆ticas.", |
| "pdf_label": "RECOMENDACI脫N: REVISI脫N HUMANA", |
| "action_final": "REVISI脫N HUMANA", |
| "color": "violet", |
| "reason": "El contenido activa reglas sensibles de pol铆ticas o la probabilidad fue limitada a 20% o menos.", |
| "probability": prob, |
| "visual_score": vscore, |
| "paid_score": pscore, |
| "cpm": cpm, |
| "gate_passed": gate_passed, |
| "visual_interpretation": visual_interpretation(vscore), |
| } |
|
|
| |
| if prob < 0.50 or (vscore is not None and vscore < 50) or (pscore is not None and pscore < 50): |
| return { |
| "key": "no", |
| "label": "NO INVIERTAS", |
| "headline": "RECOMENDACI脫N: NO INVERTIR", |
| "ui_label": "馃敶 No inviertas: las se帽ales no justifican pauta.", |
| "pdf_label": "RECOMENDACI脫N: NO PAUTAR", |
| "action_final": "NO IMPULSAR", |
| "color": "red", |
| "reason": "La probabilidad publicitaria, el score de pauta o el score visual est谩n por debajo del umbral m铆nimo.", |
| "probability": prob, |
| "visual_score": vscore, |
| "paid_score": pscore, |
| "cpm": cpm, |
| "gate_passed": gate_passed, |
| "visual_interpretation": visual_interpretation(vscore), |
| } |
|
|
| |
| regular_visual = vscore is not None and 60 <= vscore <= 70 |
| regular_paid = pscore is not None and 50 <= pscore < 65 |
| regular_cpm = cpm >= 7.0 |
| if (not eligible) or regular_visual or regular_paid or regular_cpm: |
| return { |
| "key": "adjust", |
| "label": "REALIZA AJUSTES ANTES DE INVERTIR", |
| "headline": "RECOMENDACI脫N: AJUSTAR ANTES DE INVERTIR", |
| "ui_label": "馃煛 Realiza ajustes antes de invertir: hay potencial, pero no conviene escalar todav铆a.", |
| "pdf_label": "RECOMENDACI脫N: AJUSTAR ANTES DE PAUTAR", |
| "action_final": "AJUSTAR ANTES DE IMPULSAR", |
| "color": "yellow", |
| "reason": "El video muestra se帽ales parciales, pero requiere optimizaci贸n creativa, visual o de eficiencia antes de escalar presupuesto.", |
| "probability": prob, |
| "visual_score": vscore, |
| "paid_score": pscore, |
| "cpm": cpm, |
| "gate_passed": gate_passed, |
| "visual_interpretation": visual_interpretation(vscore), |
| } |
|
|
| |
| if prob >= 0.51 and eligible and (vscore is None or vscore >= 71) and (pscore is None or pscore >= 65): |
| return { |
| "key": "invest", |
| "label": "INVIERTE", |
| "headline": "RECOMENDACI脫N: INVERTIR", |
| "ui_label": "馃煝 Invierte: el video cumple el umbral de aptitud publicitaria.", |
| "pdf_label": "RECOMENDACI脫N: PAUTAR", |
| "action_final": "IMPULSAR", |
| "color": "green", |
| "reason": "El video supera el umbral de regresi贸n log铆stica y no presenta bloqueos visuales o de pol铆tica.", |
| "probability": prob, |
| "visual_score": vscore, |
| "paid_score": pscore, |
| "cpm": cpm, |
| "gate_passed": gate_passed, |
| "visual_interpretation": visual_interpretation(vscore), |
| } |
|
|
| return { |
| "key": "adjust", |
| "label": "REALIZA AJUSTES ANTES DE INVERTIR", |
| "headline": "RECOMENDACI脫N: AJUSTAR ANTES DE INVERTIR", |
| "ui_label": "馃煛 Realiza ajustes antes de invertir: se帽ales mixtas.", |
| "pdf_label": "RECOMENDACI脫N: AJUSTAR ANTES DE PAUTAR", |
| "action_final": "AJUSTAR ANTES DE IMPULSAR", |
| "color": "yellow", |
| "reason": "Las se帽ales son mixtas; conviene optimizar y volver a evaluar.", |
| "probability": prob, |
| "visual_score": vscore, |
| "paid_score": pscore, |
| "cpm": cpm, |
| "gate_passed": gate_passed, |
| "visual_interpretation": visual_interpretation(vscore), |
| } |
|
|
|
|
| def integrated_markdown(decision: Dict[str, Any]) -> str: |
| vscore = decision.get("visual_score") |
| pscore = decision.get("paid_score") |
| prob = _sf(decision.get("probability"), 0.0) * 100 |
| cpm = _sf(decision.get("cpm"), 0.0) |
| visual_text = decision.get("visual_interpretation", {}).get("summary", "") |
| return ( |
| "### Visi贸n integradora de decisi贸n\n\n" |
| f"**{decision.get('ui_label', decision.get('label'))}**\n\n" |
| f"- Probabilidad de pauta del modelo principal: **{prob:.1f}%**.\n" |
| f"- Score de pauta XGBoost/calibrado: **{pscore:.1f}/100**.\n" if pscore is not None else |
| "### Visi贸n integradora de decisi贸n\n\n" |
| f"**{decision.get('ui_label', decision.get('label'))}**\n\n" |
| f"- Probabilidad de pauta del modelo principal: **{prob:.1f}%**.\n" |
| ) + ( |
| f"- Score visual/composici贸n: **{vscore:.0f}/100**. {visual_text}\n" if vscore is not None else |
| "- Score visual/composici贸n: **sin video o sin frames suficientes**.\n" |
| ) + ( |
| f"- CPM estimado: **${cpm:.2f}**.\n" |
| f"- Raz贸n integrada: {decision.get('reason', 'Se integraron modelos, pol铆ticas y se帽ales multimodales.')}\n" |
| ) |
|
|