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
This commit is contained in:
Apple
2026-03-03 07:14:14 -08:00
parent e9dedffa48
commit 129e4ea1fc
241 changed files with 69349 additions and 0 deletions

View File

@@ -0,0 +1,362 @@
"""
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