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
252 lines
11 KiB
Python
252 lines
11 KiB
Python
"""
|
||
doc_focus.py — Doc Focus Gate helpers (v3.5 / v3.6 / v3.7).
|
||
|
||
Без залежностей від crewai/agromatrix_tools — тільки re і stdlib.
|
||
Імпортується з run.py і operator_commands.py.
|
||
|
||
Публічні функції:
|
||
_is_doc_question(text) → bool
|
||
_detect_domain(text, logger) → str
|
||
detect_context_signals(text) → dict
|
||
build_mode_clarifier(text) → str
|
||
handle_doc_focus(sub, chat_id) → dict
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import time
|
||
|
||
# ── Тригери: повідомлення явно про документ ──────────────────────────────────
|
||
_DOC_QUESTION_RE = re.compile(
|
||
r"звіт|документ|таблиц|xlsx|sheet|рядок|колонк|в\s+звіті|у\s+файлі|у\s+документі"
|
||
r"|по\s+звіту|з\s+(?:цього\s+)?файлу|в\s+цьому\s+документі|по\s+документу"
|
||
r"|з\s+документа|відкрий\s+звіт",
|
||
re.IGNORECASE | re.UNICODE,
|
||
)
|
||
# Фінансові тригери ТІЛЬКИ якщо є прив'язка до "документу/файлу"
|
||
_DOC_FINANCIAL_RE = re.compile(
|
||
r"(?:прибуток|витрати?|собівартість|дохід|надходж|виручк|добрив|насінн|площ|гектар|грн|грн/га)"
|
||
r".*(?:звіт|документ|файл|xlsx)|"
|
||
r"(?:звіт|документ|файл|xlsx).*(?:прибуток|витрати?|дохід|грн|грн/га|площ)",
|
||
re.IGNORECASE | re.UNICODE,
|
||
)
|
||
|
||
# ── Explicit doc-токени (перемагають vision) ─────────────────────────────────
|
||
_EXPLICIT_DOC_TOKEN_RE = re.compile(
|
||
r"по\s+звіту|у\s+файлі|в\s+файлі|у\s+документі|в\s+документі|з\s+таблиц"
|
||
r"|у\s+звіті|в\s+звіті|по\s+документу|з\s+документ|у\s+цьому\s+(?:файлі|звіті|документі)",
|
||
re.IGNORECASE | re.UNICODE,
|
||
)
|
||
|
||
# ── Тригери що СКАСОВУЮТЬ doc-режим ──────────────────────────────────────────
|
||
_URL_RE = re.compile(r"https?://\S+", re.IGNORECASE)
|
||
_VISION_RE = re.compile(
|
||
r"фото|картинк|зображенн|листя|плями|шкідник|хвороба|бур'ян|бурян"
|
||
r"|рослин|гриб|гниль|хлороз|некроз|личинк|жук|кліщ|тля",
|
||
re.IGNORECASE | re.UNICODE,
|
||
)
|
||
_ACTION_OPS_RE = re.compile(
|
||
r"^(?:зроби|план|внеси|зафіксуй|перевір|порахуй|додай|видали|оновни|відкрий|нагадай)",
|
||
re.IGNORECASE | re.UNICODE,
|
||
)
|
||
_WEB_INTENT_RE = re.compile(
|
||
r"каталог|сайт|посиланн|переглянь\s+сторінк|вивч[иі]\s+каталог|знайди\s+на\s+сайт",
|
||
re.IGNORECASE | re.UNICODE,
|
||
)
|
||
|
||
# ── v3.6: Fact-signal — числові запити без прив'язки до "звіту" ──────────────
|
||
_FACT_UNITS_RE = re.compile(
|
||
r"грн|uah|₴|га\b|ha\b|%|грн/га|uah/ha|тис\.?|млн\.?|\d+\s*(?:грн|га|ha|%)",
|
||
re.IGNORECASE | re.UNICODE,
|
||
)
|
||
_FACT_WORDS_RE = re.compile(
|
||
r"прибуток|витрати?|виручка|дохід|маржа|площа|добрива|насіння|паливо|оренда|собівартість",
|
||
re.IGNORECASE | re.UNICODE,
|
||
)
|
||
|
||
# ── v3.7: UX-фрази для заміни ────────────────────────────────────────────────
|
||
_DOC_AWARENESS_RE = re.compile(
|
||
r"(так,\s*пам['\u2019]ятаю|не\s+бачу\s+його|не\s+бачу\s+перед\s+собою"
|
||
r"|мені\s+(?:не\s+)?доступний\s+документ)",
|
||
re.IGNORECASE | re.UNICODE,
|
||
)
|
||
_VISION_INTRO_RE = re.compile(
|
||
r"^на\s+фото\s+видно",
|
||
re.IGNORECASE | re.UNICODE,
|
||
)
|
||
|
||
|
||
def _is_doc_question(text: str) -> bool:
|
||
"""
|
||
Rule-based: чи питання явно про документ/звіт.
|
||
Explicit doc-токен перемагає vision-слова (скрін таблиці + caption).
|
||
Fail-safe: будь-яка помилка → False.
|
||
"""
|
||
try:
|
||
t = text.strip()
|
||
if _URL_RE.search(t):
|
||
return False
|
||
if _WEB_INTENT_RE.search(t):
|
||
return False
|
||
if _EXPLICIT_DOC_TOKEN_RE.search(t):
|
||
return True
|
||
if _VISION_RE.search(t):
|
||
return False
|
||
if _DOC_QUESTION_RE.search(t):
|
||
return True
|
||
if _DOC_FINANCIAL_RE.search(t):
|
||
return True
|
||
return False
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _detect_domain(text: str, logger=None) -> str:
|
||
"""
|
||
Визначає домен повідомлення.
|
||
Повертає: "doc" | "vision" | "web" | "ops" | "general"
|
||
|
||
Пріоритети:
|
||
URL/web > explicit_doc_token > загальні doc-тригери > vision > ops > general
|
||
Порожній текст (caption відсутній) → "vision".
|
||
"""
|
||
try:
|
||
t = text.strip()
|
||
if not t:
|
||
return "vision"
|
||
if _URL_RE.search(t) or _WEB_INTENT_RE.search(t):
|
||
return "web"
|
||
if _EXPLICIT_DOC_TOKEN_RE.search(t):
|
||
if _VISION_RE.search(t) and logger:
|
||
try:
|
||
logger.info(
|
||
"AGX_STEPAN_METRIC domain_override from=vision to=doc reason=explicit_doc_tokens"
|
||
)
|
||
except Exception:
|
||
pass
|
||
return "doc"
|
||
if _DOC_QUESTION_RE.search(t) or _DOC_FINANCIAL_RE.search(t):
|
||
return "doc"
|
||
if _VISION_RE.search(t):
|
||
return "vision"
|
||
if _ACTION_OPS_RE.search(t):
|
||
return "ops"
|
||
return "general"
|
||
except Exception:
|
||
return "general"
|
||
|
||
|
||
def detect_context_signals(text: str) -> dict:
|
||
"""
|
||
v3.6: Повертає словник булевих сигналів для doc-mode gating.
|
||
|
||
Ключі:
|
||
has_explicit_doc_token: bool — "по звіту", "у файлі" тощо
|
||
has_doc_trigger: bool — загальні doc-тригери (звіт, документ)
|
||
has_vision_trigger: bool — листя, шкідник, фото...
|
||
has_url: bool — http(s)://...
|
||
has_web_intent: bool — каталог, сайт...
|
||
has_fact_signal: bool — числові одиниці або фін-слова
|
||
"""
|
||
try:
|
||
t = text.strip()
|
||
return {
|
||
"has_explicit_doc_token": bool(_EXPLICIT_DOC_TOKEN_RE.search(t)),
|
||
"has_doc_trigger": bool(
|
||
_DOC_QUESTION_RE.search(t) or _DOC_FINANCIAL_RE.search(t)
|
||
),
|
||
"has_vision_trigger": bool(_VISION_RE.search(t)),
|
||
"has_url": bool(_URL_RE.search(t)),
|
||
"has_web_intent": bool(_WEB_INTENT_RE.search(t)),
|
||
"has_fact_signal": bool(_FACT_UNITS_RE.search(t) or _FACT_WORDS_RE.search(t)),
|
||
}
|
||
except Exception:
|
||
return {
|
||
"has_explicit_doc_token": False, "has_doc_trigger": False,
|
||
"has_vision_trigger": False, "has_url": False,
|
||
"has_web_intent": False, "has_fact_signal": False,
|
||
}
|
||
|
||
|
||
def build_mode_clarifier(text: str) -> str:
|
||
"""
|
||
v3.6/v3.7: Одне контекстне уточнююче питання (без "!", без "будь ласка").
|
||
|
||
URL → "Ти про посилання чи про звіт?"
|
||
vision → "Це про фото чи про цифри зі звіту?"
|
||
facts → "Це про конкретні цифри зі звіту?"
|
||
інше → "Йдеться про звіт чи про інше?"
|
||
"""
|
||
try:
|
||
t = text.strip()
|
||
if _URL_RE.search(t):
|
||
return "Ти про посилання чи про звіт?"
|
||
if _VISION_RE.search(t):
|
||
return "Це про фото чи про цифри зі звіту?"
|
||
if _FACT_UNITS_RE.search(t) or _FACT_WORDS_RE.search(t):
|
||
return "Це про конкретні цифри зі звіту?"
|
||
return "Йдеться про звіт чи про інше?"
|
||
except Exception:
|
||
return "Йдеться про звіт чи про інше?"
|
||
|
||
|
||
def handle_doc_focus(sub: str, chat_id: str | None = None) -> dict:
|
||
"""
|
||
/doc [on|off|status].
|
||
|
||
/doc on → doc_focus=True, TTL = DOC_FOCUS_TTL, cooldown скинутий
|
||
/doc off → doc_focus=False
|
||
/doc status → поточний стан (focus, ttl_left, cooldown_left, active_doc_id, facts)
|
||
"""
|
||
def _wrap(msg: str) -> dict:
|
||
return {"ok": True, "message": msg}
|
||
|
||
try:
|
||
from crews.agromatrix_crew.session_context import (
|
||
_STORE, DOC_FOCUS_TTL, is_doc_focus_active, load_session,
|
||
is_doc_focus_cooldown_active,
|
||
)
|
||
except ImportError:
|
||
return _wrap("session_context not available")
|
||
|
||
if not chat_id:
|
||
return _wrap("chat_id required for /doc command")
|
||
|
||
now = time.time()
|
||
|
||
if sub == "on":
|
||
existing = _STORE.get(str(chat_id)) or {}
|
||
existing["doc_focus"] = True
|
||
existing["doc_focus_ts"] = now
|
||
existing["doc_focus_cooldown_until"] = 0.0 # /doc on скидає cooldown
|
||
_STORE[str(chat_id)] = existing
|
||
doc_id = existing.get("active_doc_id") or "—"
|
||
return _wrap(f"doc_focus=on. Документ: {str(doc_id)[:20]}. TTL={int(DOC_FOCUS_TTL)}с.")
|
||
|
||
if sub == "off":
|
||
existing = _STORE.get(str(chat_id)) or {}
|
||
existing["doc_focus"] = False
|
||
existing["doc_focus_ts"] = 0.0
|
||
_STORE[str(chat_id)] = existing
|
||
return _wrap("doc_focus=off. Степан відповідатиме без прив'язки до документа.")
|
||
|
||
# status (default)
|
||
session = load_session(str(chat_id))
|
||
focus_active = is_doc_focus_active(session, now)
|
||
cooldown_active = is_doc_focus_cooldown_active(session, now)
|
||
doc_id = session.get("active_doc_id") or "—"
|
||
doc_facts = session.get("doc_facts") or {}
|
||
ttl_left = max(0.0, DOC_FOCUS_TTL - (now - (session.get("doc_focus_ts") or 0.0)))
|
||
cooldown_left = max(0.0, (session.get("doc_focus_cooldown_until") or 0.0) - now)
|
||
facts_keys = (
|
||
", ".join(k for k in doc_facts if k not in ("conflicts", "needs_recheck"))
|
||
if doc_facts else "—"
|
||
)
|
||
cooldown_str = f" cooldown={int(cooldown_left)}с" if cooldown_active else ""
|
||
return _wrap(
|
||
f"doc_focus={'on' if focus_active else 'off'} "
|
||
f"ttl_left={int(ttl_left)}с{cooldown_str} | "
|
||
f"active_doc_id={str(doc_id)[:20]} | "
|
||
f"facts=[{facts_keys}]"
|
||
)
|