""" Soft Proactivity Layer — Humanized Stepan v3. Додає РІВНО 1 коротке речення в кінець deep-відповіді за суворих умов. Rule-based, без LLM. Умови спрацювання (всі мають виконуватись одночасно): 1. depth == "deep" 2. reflection is None OR reflection["confidence"] >= 0.7 3. interaction_count % 10 == 0 (кожна 10-та взаємодія) 4. В known_intents один intent зустрівся >= 3 рази 5. НЕ (preferred_style == "brief" AND response вже містить "?") Речення ≤ 120 символів, без "!". Telemetry: AGX_STEPAN_METRIC proactivity_added user_id=h:... intent=... style=... AGX_STEPAN_METRIC proactivity_skipped reason=... (якщо умови не пройдені) """ from __future__ import annotations import logging import random from typing import Any from crews.agromatrix_crew.telemetry import tlog logger = logging.getLogger(__name__) # ─── Phrase banks ───────────────────────────────────────────────────────────── _PROACTIVE_GENERIC = [ "За потреби можу швидко зібрати план/факт за вчора.", "Якщо хочеш, можу підготувати короткий чек-лист на ранок.", "Можу також порівняти з попереднім тижнем — скажи якщо потрібно.", "Якщо зміниться пріоритет — одразу скажи, скорегуємо.", "Якщо потрібна деталізація по конкретному полю — кажи.", "Готовий зібрати зведення по полях якщо буде потреба.", "Можу також перевірити статуси по відкритих задачах.", ] _PROACTIVE_IOT = [ "Якщо хочеш, перевірю датчики по ключових полях.", "Можу також відслідкувати вологість по полях у реальному часі.", "За потреби — швидкий звіт по датчиках.", "Якщо є аномалії на датчиках — дам знати одразу.", ] _PROACTIVE_PLAN = [ "За потреби можу оновити план після нових даних.", "Якщо хочеш — зведу всі задачі на тиждень в один список.", "Можу ще раз пройтись по пріоритетах якщо щось зміниться.", "Якщо план зміниться — оновлю фільтри автоматично.", ] _PROACTIVE_SUSTAINABILITY = [ "Можу також подивитись показники сталості за вибраний період.", "Якщо потрібно — порівняємо з нормою по регіону.", ] # intent → bank mapping _INTENT_BANK: dict[str, list[str]] = { "iot_sensors": _PROACTIVE_IOT, "plan_day": _PROACTIVE_PLAN, "plan_week": _PROACTIVE_PLAN, "plan_vs_fact": _PROACTIVE_PLAN, "sustainability": _PROACTIVE_SUSTAINABILITY, } def _top_intent(known_intents: list | None) -> tuple[str | None, int]: """ Знаходить intent з найвищою частотою у known_intents. known_intents = list[str] (повторення дозволені, кожен запис = 1 взаємодія). Повертає (intent, count) або (None, 0). """ if not known_intents: return None, 0 freq: dict[str, int] = {} for item in known_intents: if isinstance(item, str): freq[item] = freq.get(item, 0) + 1 if not freq: return None, 0 top = max(freq, key=lambda k: freq[k]) return top, freq[top] def maybe_add_proactivity( response: str, user_profile: dict, depth: str, reflection: dict | None = None, ) -> tuple[str, bool]: """ Можливо додає 1 проактивне речення до відповіді. Аргументи: response — поточна відповідь Степана user_profile — UserProfile dict depth — "light" або "deep" reflection — результат reflect_on_response або None Повертає: (new_response, was_added: bool) """ user_id = user_profile.get("user_id", "") try: # Умова 1: тільки deep if depth != "deep": tlog(logger, "proactivity_skipped", user_id=user_id, reason="not_deep") return response, False # Умова 2: confidence >= 0.7 або reflection відсутній if reflection is not None: confidence = reflection.get("confidence", 1.0) if confidence < 0.7: tlog(logger, "proactivity_skipped", user_id=user_id, reason="low_confidence", confidence=round(confidence, 2)) return response, False # Умова 3: interaction_count % 10 == 0 count = user_profile.get("interaction_count", 0) if count == 0 or count % 10 != 0: tlog(logger, "proactivity_skipped", user_id=user_id, reason="not_tenth", interaction_count=count) return response, False # Умова 4: top intent зустрічався >= 3 рази known_intents = user_profile.get("known_intents", []) top_intent, top_count = _top_intent(known_intents) if top_count < 3: tlog(logger, "proactivity_skipped", user_id=user_id, reason="intent_freq_low", top_intent=top_intent, top_count=top_count) return response, False # Умова 5: не нав'язувати якщо brief і вже є питання preferred_style = user_profile.get("preferences", {}).get("report_format", "") style = user_profile.get("style", "") is_brief = preferred_style == "brief" or style == "concise" if is_brief and "?" in response: tlog(logger, "proactivity_skipped", user_id=user_id, reason="brief_with_question", style=style) return response, False # Обрати банк фраз за intent bank = _INTENT_BANK.get(top_intent or "", _PROACTIVE_GENERIC) seed = hash(f"{user_id}:{count}") % (2**32) rng = random.Random(seed) phrase = rng.choice(bank) # Гарантуємо ≤ 120 символів і без "!" phrase = phrase[:120].replace("!", "") new_response = response.rstrip() + "\n\n" + phrase tlog(logger, "proactivity_added", user_id=user_id, intent=top_intent, style=style) return new_response, True except Exception as exc: logger.warning("maybe_add_proactivity error (no-op): %s", exc) return response, False