Files
microdao-daarion/crews/agromatrix_crew/doc_focus.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

252 lines
11 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.
"""
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}]"
)