New router intelligence modules (26 files): alert_ingest/store, audit_store, architecture_pressure, backlog_generator/store, cost_analyzer, data_governance, dependency_scanner, drift_analyzer, incident_* (5 files), llm_enrichment, platform_priority_digest, provider_budget, release_check_runner, risk_* (6 files), signature_state_store, sofiia_auto_router, tool_governance New services: - sofiia-console: Dockerfile, adapters/, monitor/nodes/ops/voice modules, launchd, react static - memory-service: integration_endpoints, integrations, voice_endpoints, static UI - aurora-service: full app suite (analysis, job_store, orchestrator, reporting, schemas, subagents) - sofiia-supervisor: new supervisor service - aistalk-bridge-lite: Telegram bridge lite - calendar-service: CalDAV calendar service with reminders - mlx-stt-service / mlx-tts-service: Apple Silicon speech services - binance-bot-monitor: market monitor service - node-worker: STT/TTS memory providers New tools (9): agent_email, browser_tool, contract_tool, observability_tool, oncall_tool, pr_reviewer_tool, repo_tool, safe_code_executor, secure_vault New crews: agromatrix_crew (10 modules: depth_classifier, doc_facts, doc_focus, farm_state, light_reply, llm_factory, memory_manager, proactivity, reflection_engine, session_context, style_adapter, telemetry) Tests: 85+ test files for all new modules Made-with: Cursor
363 lines
18 KiB
Python
363 lines
18 KiB
Python
"""
|
||
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
|