""" Human Light Reply — варіативні відповіді для Light mode Степана. Без LLM. Без рефакторингу архітектури. Seeded randomness: стабільна варіативність на основі sha256(user_id + current_day). - Стабільна в межах одного дня (не "скаче" між повідомленнями). - Змінюється щодня (не "скриптова" через місяць). Типи light-подій: greeting — "привіт", "добрий ранок", … thanks — "дякую", "спасибі", … ack — "ок", "зрозумів", "добре", "чудово", … short_followup — ≤6 слів, є last_topic, немає action verbs weather_followup — "а якщо дощ?", "мороз", "вітер" + last_topic + FarmProfile Greeting без теми: 3 режими залежно від interaction_count: neutral (count 0–2): "На звʼязку." / "Слухаю." soft (count 3–7): "Що сьогодні рухаємо?" contextual (count 8+): "По плануванню чи по датчиках?" Правила: - Якщо є name → звертатись по імені (1 раз на greeting) - Якщо є last_topic → підхоплення теми на greeting / short_followup - На thanks/ack → 2–6 слів, без питань - Одне питання максимум, вибір з двох (без слова "оберіть") - Заборонено: "чим допомогти", шаблонні вступи, запуск систем, згадки помилок Fail-safe: будь-який виняток → None (fallback до LLM). """ from __future__ import annotations import hashlib import logging import random import re from datetime import date logger = logging.getLogger(__name__) # ─── Topic label map ───────────────────────────────────────────────────────── _TOPIC_LABELS: dict[str, str] = { "plan_day": "план на день", "plan_week": "план на тиждень", "plan_vs_fact": "план/факт", "show_critical_tomorrow": "критичні задачі на завтра", "close_plan": "закриття плану", "iot_status": "стан датчиків", "general": "попереднє питання", } def _topic_label(last_topic: str | None) -> str: if not last_topic: return "попередню тему" return _TOPIC_LABELS.get(last_topic, last_topic.replace("_", " ")) # ─── Phrase banks ───────────────────────────────────────────────────────────── _GREETING_WITH_TOPIC: list[str] = [ "Привіт{name}. По {topic} є оновлення, чи рухаємось за планом?", "Привіт{name}. {topic_cap} — ще актуально чи є нова задача?", "Привіт{name}. Продовжуємо з {topic}, чи щось змінилось?", "Привіт{name}. Що по {topic} — є нові дані?", "Привіт{name}. По {topic} все гаразд чи треба щось уточнити?", "Привіт{name}. {topic_cap} — рухаємось далі чи є зміни?", ] # Greeting без теми — 3 рівні природності залежно від interaction_count # Рівень 0–2 (новий або рідко спілкується): нейтральний, без питань _GREETING_NEUTRAL: list[str] = [ "На звʼязку{name}.", "Слухаю{name}.", "Привіт{name}.", "Так{name}?", ] # Рівень 3–7 (починає звикати): м'який відкритий промпт _GREETING_SOFT: list[str] = [ "Привіт{name}. Що сьогодні рухаємо?", "Привіт{name}. З чого починаємо?", "Привіт{name}. Є нова задача?", "Привіт{name}. Що по плану?", "Привіт{name}. Що маємо сьогодні?", ] # Рівень 8+ (знайомий): контекстна здогадка _GREETING_CONTEXTUAL: list[str] = [ "Привіт{name}. По плануванню чи по датчиках?", "Привіт{name}. Операції чи аналітика?", "Привіт{name}. Польові чи офісні питання?", "Привіт{name}. Що сьогодні — план чи факт?", ] _THANKS: list[str] = [ "Прийняв.", "Добре.", "Зрозумів.", "Ок.", "Домовились.", "Тримаю в курсі.", "Прийнято.", "Зафіксував.", ] _ACK: list[str] = [ "Ок, продовжуємо.", "Прийнято.", "Зрозумів.", "Добре.", "Ок.", "Зафіксував.", "Чітко.", "Прийняв.", ] _SHORT_FOLLOWUP_WITH_TOPIC: list[str] = [ "По {topic} — {text_frag}", "Щодо {topic}: {text_frag}", "Так, по {topic} — {text_frag}", "По {topic} є деталі. {text_frag}", "Стосовно {topic}: {text_frag}", ] _OFFTOPIC: list[str] = [ "Я можу допомогти з роботами або даними ферми. Що саме потрібно зробити?", "Це не моя ділянка. Щодо ферми або операцій — скажи, що треба.", "Готовий допомогти з польовими операціями або аналітикою. Що конкретно?", "Моя область — агровиробництво і дані ферми. Скажи, що потрібно.", ] # ─── Weather mini-knowledge ─────────────────────────────────────────────────── _WEATHER_RE = re.compile( r'\b(дощ|злива|мороз|заморозк|вітер|спека|суша|туман|град|сніг|опади)\w*\b', re.IGNORECASE | re.UNICODE, ) # rule-based відповіді: (weather_word_stem, phase_hint) → reply # Досить 5–7 найчастіших випадків. _WEATHER_RULES: list[tuple[str, str | None, str]] = [ # (тригер-підрядок, фаза або None, відповідь) ("дощ", "growing", "Якщо дощ — переносимо обробку на вікно після висихання ґрунту (зазвичай 1–2 доби)."), ("дощ", "sowing", "Дощ під час сівби: зупиняємо якщо злива, продовжуємо при легкому."), ("дощ", None, "Якщо дощ — обробка відкладається. Уточни фазу?"), ("злива", None, "Злива — зупиняємо польові роботи до стабілізації."), ("мороз", "growing", "Заморозки в фазу вегетації — критично. Перевір поріг чутливості культури."), ("мороз", "sowing", "Мороз під час сівби — призупиняємо. Насіння не проростає нижче +5°C."), ("мороз", None, "При морозі — польові роботи під питанням. Яка культура?"), ("спека", "growing", "Спека понад 35°C — збільш полив якщо є зрошення, контролюй IoT."), ("вітер", None, "Сильний вітер — обприскування не проводимо."), ("суша", "growing", "Суха погода в вегетацію — пріоритет зрошення."), ("заморозк", None, "Заморозки — перевір чутливість культури і стан плівки/укриття."), ] _ZZR_RE = re.compile( r'\b(обробк|обприскування|гербіцид|фунгіцид|ЗЗР|пестицид|інсектицид|протруювач)\w*\b', re.IGNORECASE | re.UNICODE, ) _ZZR_DISCLAIMER = " Дозування та вікна застосування — за етикеткою препарату та регламентом." def _weather_reply(text: str, farm_profile: dict | None) -> str | None: """ Повертає коротку правильну відповідь якщо текст містить погодний тригер. Враховує FarmProfile.season_state якщо доступний. Якщо текст також містить ЗЗР-тригери — додає застереження про регламент. Повертає None якщо погодного тригера немає. """ if not _WEATHER_RE.search(text): return None tl = text.lower() phase = (farm_profile or {}).get("season_state") or (farm_profile or {}).get("seasonal_context", {}).get("current_phase") has_zzr = bool(_ZZR_RE.search(text)) for trigger, rule_phase, reply in _WEATHER_RULES: if trigger in tl: if rule_phase is None or rule_phase == phase: return reply + (_ZZR_DISCLAIMER if has_zzr else "") return None # ─── Seeded RNG ─────────────────────────────────────────────────────────────── def _seeded_rng(user_id: str | None, day: str | None = None) -> random.Random: """ Повертає Random зі стабільним seed на основі sha256(user_id + current_day). Стабільний в межах дня — інший завтра. sha256 замість hash() — бо builtin hash() солиться per-process. """ if not user_id: return random.Random(42) today = day or date.today().isoformat() raw = f"{user_id}:{today}" seed_int = int(hashlib.sha256(raw.encode()).hexdigest(), 16) % (2**32) return random.Random(seed_int) def _pick(rng: random.Random, options: list[str]) -> str: return rng.choice(options) # ─── Event classifiers ──────────────────────────────────────────────────────── _GREETING_RE = re.compile( r'^(привіт|добрий\s+\w+|доброго\s+\w+|hello|hi|hey|вітаю|вітання|hey stepan|привітання)[\W]*$', re.IGNORECASE | re.UNICODE, ) _THANKS_RE = re.compile( r'^(дякую|дякуй|спасибі|дякую степан|велике дякую|щиро дякую)[\W]*$', re.IGNORECASE | re.UNICODE, ) _ACK_RE = re.compile( r'^(ок|окей|добре|зрозумів|зрозуміла|ясно|зрозуміло|чудово|супер|ага|угу|так|о[кк])[\W]*$', re.IGNORECASE | re.UNICODE, ) _ACTION_VERB_RE = re.compile( r'\b(зроби|перевір|порахуй|підготуй|онови|створи|запиши|зафіксуй|внеси' r'|проаналізуй|порівняй|розрахуй|сплануй|покажи|заплануй|закрий|відкрий)\b', re.IGNORECASE | re.UNICODE, ) _URGENT_RE = re.compile( r'\b(аварія|терміново|критично|тривога|невідкладно|alert|alarm|critical)\b', re.IGNORECASE | re.UNICODE, ) def _is_short_followup(text: str, last_topic: str | None) -> bool: """Коротка репліка (≤6 слів) з last_topic і без action verbs → light follow-up.""" words = text.strip().split() if len(words) > 6: return False if last_topic is None: return False if _ACTION_VERB_RE.search(text): return False if _URGENT_RE.search(text): return False return True # ─── Main API ───────────────────────────────────────────────────────────────── def classify_light_event(text: str, last_topic: str | None) -> str | None: """ Класифікує текст у тип light-події. Повертає: 'greeting' | 'thanks' | 'ack' | 'short_followup' | 'offtopic' | None None → not a clear light event (caller should use LLM path) """ t = text.strip() if _GREETING_RE.match(t): return "greeting" if _THANKS_RE.match(t): return "thanks" if _ACK_RE.match(t): return "ack" if _is_short_followup(t, last_topic): return "short_followup" return None def _pick_recent_label(rng: random.Random, user_profile: dict) -> str | None: """ З ймовірністю 0.2 (seeded) повертає тему recent_topics[-2] замість останньої. Це дає відчуття що Степан пам'ятає більше ніж 1 тему, але не нав'язливо. Ніколи не повертає дві теми одразу. """ topics = user_profile.get("recent_topics", []) if len(topics) < 2: return user_profile.get("last_topic_label") or _topic_label(user_profile.get("last_topic")) # Use seeded rng: low probability (≈20%) to pick the second-to-last topic if rng.random() < 0.2: entry = topics[-2] return entry.get("label") or _topic_label(entry.get("intent")) last = topics[-1] return last.get("label") or _topic_label(last.get("intent")) def build_light_reply( text: str, user_profile: dict | None, farm_profile: dict | None = None, light_event: str | None = None, ) -> str | None: """ Будує детерміновану (seeded) відповідь для Light mode без LLM. Повертає: str — готова відповідь (тоді LLM не потрібен) None — не підходить для без-LLM відповіді, треба LLM path Fail-safe: виняток → None (fallback до LLM). """ try: up = user_profile or {} user_id = up.get("user_id") or "" last_topic = up.get("last_topic") name = up.get("name") or "" name_suffix = f", {name}" if name else "" interaction_count = up.get("interaction_count", 0) rng = _seeded_rng(user_id) # daily seed: changes each day if light_event is None: light_event = classify_light_event(text, last_topic) # ── Weather follow-up (priority before general short_followup) ───────── if light_event == "short_followup": weather = _weather_reply(text, farm_profile) if weather: return weather # ── Greeting ────────────────────────────────────────────────────────── if light_event == "greeting": if last_topic: # Use human label if available (last_topic_label), else fallback to intent label topic = up.get("last_topic_label") or _topic_label(last_topic) # Contextual experienced users: occasionally recall second-last topic if interaction_count >= 8: topic = _pick_recent_label(rng, up) or topic template = _pick(rng, _GREETING_WITH_TOPIC) return template.format( name=name_suffix, topic=topic, topic_cap=topic[:1].upper() + topic[1:] if topic else topic, text_frag="", ).rstrip() else: # 3 levels based on how well Stepan knows this user if interaction_count <= 2: template = _pick(rng, _GREETING_NEUTRAL) elif interaction_count <= 7: template = _pick(rng, _GREETING_SOFT) else: template = _pick(rng, _GREETING_CONTEXTUAL) return template.format(name=name_suffix) # ── Thanks ──────────────────────────────────────────────────────────── if light_event == "thanks": return _pick(rng, _THANKS) # ── Ack ─────────────────────────────────────────────────────────────── if light_event == "ack": return _pick(rng, _ACK) # ── Short follow-up (no weather trigger) ────────────────────────────── if light_event == "short_followup" and last_topic: # Prefer human label over raw intent key topic = up.get("last_topic_label") or _topic_label(last_topic) text_frag = text.strip().rstrip("?").strip() template = _pick(rng, _SHORT_FOLLOWUP_WITH_TOPIC) return template.format(topic=topic, text_frag=text_frag) return None # Let LLM handle it except Exception as exc: logger.warning("light_reply.build_light_reply error: %s", exc) return None