Files
microdao-daarion/crews/agromatrix_crew/light_reply.py
Apple 129e4ea1fc feat(platform): add new services, tools, tests and crews modules
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
2026-03-03 07:14:14 -08:00

363 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 02): "На звʼязку." / "Слухаю."
soft (count 37): "Що сьогодні рухаємо?"
contextual (count 8+): "По плануванню чи по датчиках?"
Правила:
- Якщо є name → звертатись по імені (1 раз на greeting)
- Якщо є last_topic → підхоплення теми на greeting / short_followup
- На thanks/ack → 26 слів, без питань
- Одне питання максимум, вибір з двох (без слова "оберіть")
- Заборонено: "чим допомогти", шаблонні вступи, запуск систем, згадки помилок
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
# Рівень 02 (новий або рідко спілкується): нейтральний, без питань
_GREETING_NEUTRAL: list[str] = [
"На звʼязку{name}.",
"Слухаю{name}.",
"Привіт{name}.",
"Так{name}?",
]
# Рівень 37 (починає звикати): м'який відкритий промпт
_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
# Досить 57 найчастіших випадків.
_WEATHER_RULES: list[tuple[str, str | None, str]] = [
# (тригер-підрядок, фаза або None, відповідь)
("дощ", "growing", "Якщо дощ — переносимо обробку на вікно після висихання ґрунту (зазвичай 12 доби)."),
("дощ", "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