fix(fabric): use broadcast subject for NATS capabilities discovery
NATS wildcards (node.*.capabilities.get) only work for subscriptions, not for publish. Switch to a dedicated broadcast subject (fabric.capabilities.discover) that all NCS instances subscribe to, enabling proper scatter-gather discovery across nodes. Made-with: Cursor
This commit is contained in:
10
.env.example
10
.env.example
@@ -59,6 +59,16 @@ DEEPSEEK_BASE_URL=https://api.deepseek.com
|
|||||||
# OpenAI API (optional)
|
# OpenAI API (optional)
|
||||||
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# Notion integration (optional)
|
||||||
|
NOTION_API_KEY=
|
||||||
|
NOTION_VERSION=2022-06-28
|
||||||
|
|
||||||
|
# OpenCode HTTP endpoint for status probe (optional)
|
||||||
|
OPENCODE_URL=
|
||||||
|
|
||||||
|
# Optional per-node SSH auth (used by sofiia-console /api/nodes/ssh/status)
|
||||||
|
NODES_NODA1_SSH_PASSWORD=
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# DAGI Router Configuration
|
# DAGI Router Configuration
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1304,7 +1304,7 @@ agents:
|
|||||||
- security
|
- security
|
||||||
- evolution
|
- evolution
|
||||||
|
|
||||||
llm_profile: reasoning
|
llm_profile: grok
|
||||||
prompt_file: sofiia_prompt.txt
|
prompt_file: sofiia_prompt.txt
|
||||||
|
|
||||||
access_control:
|
access_control:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from crewai import Agent
|
from crewai import Agent
|
||||||
from crews.agromatrix_crew import tools
|
from crews.agromatrix_crew.llm_factory import make_llm
|
||||||
|
|
||||||
|
|
||||||
def build_iot():
|
def build_iot():
|
||||||
@@ -7,10 +7,8 @@ def build_iot():
|
|||||||
role="IoT Agent",
|
role="IoT Agent",
|
||||||
goal="Читати телеметрію ThingsBoard і публікувати події в NATS.",
|
goal="Читати телеметрію ThingsBoard і публікувати події в NATS.",
|
||||||
backstory="Доступ лише через ThingsBoard/NATS інструменти.",
|
backstory="Доступ лише через ThingsBoard/NATS інструменти.",
|
||||||
tools=[
|
tools=[],
|
||||||
tools.tool_thingsboard_read,
|
llm=make_llm(),
|
||||||
tools.tool_event_bus
|
|
||||||
],
|
|
||||||
allow_delegation=False,
|
allow_delegation=False,
|
||||||
verbose=True
|
verbose=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
from crewai import Agent
|
from crewai import Agent
|
||||||
from crews.agromatrix_crew import tools
|
from crews.agromatrix_crew.llm_factory import make_llm
|
||||||
|
|
||||||
|
# v4.3/v4.4: farmos tools — fail-safe import
|
||||||
|
# Якщо agromatrix_tools недоступні в середовищі → tools залишається порожнім.
|
||||||
|
_farmos_tools: list = []
|
||||||
|
try:
|
||||||
|
from agromatrix_tools.tool_farmos_read import farmos_ping as _farmos_ping
|
||||||
|
_farmos_tools.append(_farmos_ping)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from agromatrix_tools.tool_farmos_read import farmos_read_logs as _farmos_read_logs
|
||||||
|
_farmos_tools.append(_farmos_read_logs)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from agromatrix_tools.tool_farmos_read import farmos_search_assets as _farmos_search_assets
|
||||||
|
_farmos_tools.append(_farmos_search_assets)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def build_operations():
|
def build_operations():
|
||||||
@@ -7,10 +26,8 @@ def build_operations():
|
|||||||
role="Operations Agent",
|
role="Operations Agent",
|
||||||
goal="Операційні дії по farmOS (читання/через integration write).",
|
goal="Операційні дії по farmOS (читання/через integration write).",
|
||||||
backstory="Ти працюєш з farmOS лише через інструменти. Прямі записи заборонені.",
|
backstory="Ти працюєш з farmOS лише через інструменти. Прямі записи заборонені.",
|
||||||
tools=[
|
tools=_farmos_tools,
|
||||||
tools.tool_farmos_read,
|
llm=make_llm(),
|
||||||
tools.tool_integration_write
|
|
||||||
],
|
|
||||||
allow_delegation=False,
|
allow_delegation=False,
|
||||||
verbose=True
|
verbose=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from crewai import Agent
|
from crewai import Agent
|
||||||
from crews.agromatrix_crew import tools
|
from crews.agromatrix_crew.llm_factory import make_llm
|
||||||
|
|
||||||
|
|
||||||
def build_platform():
|
def build_platform():
|
||||||
@@ -7,10 +7,8 @@ def build_platform():
|
|||||||
role="Platform Agent",
|
role="Platform Agent",
|
||||||
goal="Платформна перевірка стану сервісів/інтеграцій.",
|
goal="Платформна перевірка стану сервісів/інтеграцій.",
|
||||||
backstory="Доступ лише через інструменти подій/читання.",
|
backstory="Доступ лише через інструменти подій/читання.",
|
||||||
tools=[
|
tools=[],
|
||||||
tools.tool_event_bus,
|
llm=make_llm(),
|
||||||
tools.tool_farmos_read
|
|
||||||
],
|
|
||||||
allow_delegation=False,
|
allow_delegation=False,
|
||||||
verbose=True
|
verbose=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from crewai import Agent
|
from crewai import Agent
|
||||||
from crews.agromatrix_crew import tools
|
from crews.agromatrix_crew.llm_factory import make_llm
|
||||||
|
|
||||||
|
|
||||||
def build_spreadsheet():
|
def build_spreadsheet():
|
||||||
@@ -7,9 +7,8 @@ def build_spreadsheet():
|
|||||||
role="Spreadsheet Agent",
|
role="Spreadsheet Agent",
|
||||||
goal="Читати/редагувати/створювати XLSX файли та формувати артефакти.",
|
goal="Читати/редагувати/створювати XLSX файли та формувати артефакти.",
|
||||||
backstory="Використовує лише spreadsheet інструмент.",
|
backstory="Використовує лише spreadsheet інструмент.",
|
||||||
tools=[
|
tools=[],
|
||||||
tools.tool_spreadsheet
|
llm=make_llm(),
|
||||||
],
|
|
||||||
allow_delegation=False,
|
allow_delegation=False,
|
||||||
verbose=True
|
verbose=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,39 @@
|
|||||||
|
from pathlib import Path
|
||||||
from crewai import Agent
|
from crewai import Agent
|
||||||
|
from crews.agromatrix_crew.llm_factory import make_llm
|
||||||
|
|
||||||
|
_PROMPT_PATH = Path(__file__).parent.parent / "stepan_system_prompt_v2.txt"
|
||||||
|
|
||||||
|
|
||||||
def build_stepan():
|
def _load_system_prompt() -> str:
|
||||||
|
try:
|
||||||
|
return _PROMPT_PATH.read_text(encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
return (
|
||||||
|
"Ти — Степан, операційний агент AgroMatrix. "
|
||||||
|
"Говориш коротко, по ділу, живою українською мовою. "
|
||||||
|
"Не пишеш сервісних повідомлень. Відповідаєш прямо."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_stepan(style_prefix: str = "") -> Agent:
|
||||||
|
"""
|
||||||
|
Будує агента Степана.
|
||||||
|
style_prefix — персоналізований prefix від style_adapter.build_style_prefix().
|
||||||
|
Якщо не передано — використовується базовий системний промпт.
|
||||||
|
"""
|
||||||
|
backstory = _load_system_prompt()
|
||||||
|
if style_prefix:
|
||||||
|
backstory = style_prefix.strip() + "\n\n" + backstory
|
||||||
return Agent(
|
return Agent(
|
||||||
role="Stepan (AgroMatrix Orchestrator)",
|
role="Stepan (AgroMatrix Operational Agent)",
|
||||||
goal="Керувати запитами користувача через делегування під-агентам і повертати єдину відповідь.",
|
goal=(
|
||||||
backstory="Ти єдиний канал спілкування з користувачем. Під-агенти працюють лише через інструменти.",
|
"Відповідати на запити точно і людяно. "
|
||||||
|
"Делегувати під-агентам лише якщо без них неможливо. "
|
||||||
|
"Повертати консолідовану відповідь без технічного сміття."
|
||||||
|
),
|
||||||
|
backstory=backstory,
|
||||||
|
llm=make_llm(),
|
||||||
allow_delegation=True,
|
allow_delegation=True,
|
||||||
verbose=True
|
verbose=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from crewai import Agent
|
from crewai import Agent
|
||||||
from crews.agromatrix_crew import tools
|
from crews.agromatrix_crew.llm_factory import make_llm
|
||||||
|
|
||||||
|
|
||||||
def build_sustainability():
|
def build_sustainability():
|
||||||
@@ -7,9 +7,8 @@ def build_sustainability():
|
|||||||
role="Sustainability Agent",
|
role="Sustainability Agent",
|
||||||
goal="Агрегати та аналітика (LiteFarm read-only).",
|
goal="Агрегати та аналітика (LiteFarm read-only).",
|
||||||
backstory="Працює лише з read-only LiteFarm інструментом.",
|
backstory="Працює лише з read-only LiteFarm інструментом.",
|
||||||
tools=[
|
tools=[],
|
||||||
tools.tool_litefarm_read
|
llm=make_llm(),
|
||||||
],
|
|
||||||
allow_delegation=False,
|
allow_delegation=False,
|
||||||
verbose=True
|
verbose=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ from nats.aio.client import Client as NATS
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
NATS_URL = os.getenv('NATS_URL', 'nats://localhost:4222')
|
NATS_URL = os.getenv('NATS_URL', 'nats://localhost:4222')
|
||||||
AUDIT_FILE = os.getenv('AGX_AUDIT_FILE', 'artifacts/audit.log.jsonl')
|
# Default: /app/logs (mounted rw volume) або /tmp як fallback
|
||||||
|
AUDIT_FILE = os.getenv('AGX_AUDIT_FILE', '/app/logs/stepan_audit.log.jsonl')
|
||||||
|
|
||||||
|
|
||||||
def _hash(text: str):
|
def _hash(text: str):
|
||||||
@@ -17,16 +18,19 @@ def _hash(text: str):
|
|||||||
|
|
||||||
async def _publish_nats(subject: str, payload: dict):
|
async def _publish_nats(subject: str, payload: dict):
|
||||||
nc = NATS()
|
nc = NATS()
|
||||||
await nc.connect(servers=[NATS_URL])
|
await nc.connect(servers=[NATS_URL], connect_timeout=2)
|
||||||
await nc.publish(subject, json.dumps(payload).encode())
|
await nc.publish(subject, json.dumps(payload).encode())
|
||||||
await nc.flush(1)
|
await nc.flush(1)
|
||||||
await nc.drain()
|
await nc.drain()
|
||||||
|
|
||||||
|
|
||||||
def audit_event(event: dict):
|
def audit_event(event: dict):
|
||||||
Path(AUDIT_FILE).parent.mkdir(parents=True, exist_ok=True)
|
try:
|
||||||
with open(AUDIT_FILE, 'a', encoding='utf-8') as f:
|
Path(AUDIT_FILE).parent.mkdir(parents=True, exist_ok=True)
|
||||||
f.write(json.dumps(event, ensure_ascii=False) + '\n')
|
with open(AUDIT_FILE, 'a', encoding='utf-8') as f:
|
||||||
|
f.write(json.dumps(event, ensure_ascii=False) + '\n')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
asyncio.run(_publish_nats('agx.audit.delegation', event))
|
asyncio.run(_publish_nats('agx.audit.delegation', event))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
"""
|
||||||
|
Operator commands for AgroMatrix (Stepan). Access control and slash commands.
|
||||||
|
|
||||||
|
Access control (env, used by gateway and here):
|
||||||
|
- AGX_OPERATOR_IDS: comma-separated Telegram user_id list; only these users are operators.
|
||||||
|
- AGX_OPERATOR_CHAT_ID: optional; if set, operator actions allowed only in this chat_id.
|
||||||
|
|
||||||
|
When is_operator(user_id, chat_id) is True, gateway routes any message (not only slash)
|
||||||
|
to Stepan for human-friendly operator interaction.
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
@@ -16,6 +26,9 @@ OPERATOR_COMMANDS = {
|
|||||||
"reject",
|
"reject",
|
||||||
"apply_dict",
|
"apply_dict",
|
||||||
"pending_stats",
|
"pending_stats",
|
||||||
|
"doc", # v3.5: Doc Focus Gate control (/doc on|off|status)
|
||||||
|
"farmos", # v4.3: FarmOS healthcheck (/farmos | /farmos status)
|
||||||
|
"farm", # v4.6: Farm state snapshot (/farm state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -375,4 +388,298 @@ def route_operator_command(text: str, user_id: str | None, chat_id: str | None):
|
|||||||
if cmd == 'pending_stats':
|
if cmd == 'pending_stats':
|
||||||
return handle_stats()
|
return handle_stats()
|
||||||
|
|
||||||
|
# ── /doc [on|off|status] (v3.5: Doc Focus Gate) ─────────────────────────
|
||||||
|
if cmd == 'doc':
|
||||||
|
sub = args[0].lower() if args else "status"
|
||||||
|
from crews.agromatrix_crew.doc_focus import handle_doc_focus as _hdf
|
||||||
|
return _hdf(sub, chat_id=chat_id)
|
||||||
|
|
||||||
|
# ── /farmos [status] (v4.3: FarmOS healthcheck) ──────────────────────────
|
||||||
|
if cmd == 'farmos':
|
||||||
|
return handle_farmos_status(args)
|
||||||
|
|
||||||
|
# ── /farm state (v4.6: FarmOS → Farm State Snapshot) ─────────────────────
|
||||||
|
if cmd == 'farm':
|
||||||
|
return handle_farm_command(args, chat_id=chat_id)
|
||||||
|
|
||||||
return _wrap('unknown command')
|
return _wrap('unknown command')
|
||||||
|
|
||||||
|
|
||||||
|
def handle_farmos_status(args: list) -> dict:
|
||||||
|
"""
|
||||||
|
/farmos [status|logs [log_type] [limit]] — FarmOS diagnostics.
|
||||||
|
Fail-closed: будь-яка внутрішня помилка → зрозумілий текст.
|
||||||
|
Не виводить URL або токени.
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
(no args) | status — healthcheck ping
|
||||||
|
logs [log_type] [limit] — останні записи farmOS
|
||||||
|
"""
|
||||||
|
sub = args[0].lower() if args else "status"
|
||||||
|
|
||||||
|
# ── /farmos logs [log_type] [limit] ──────────────────────────────────────
|
||||||
|
if sub == "logs":
|
||||||
|
log_type = "activity"
|
||||||
|
limit = 10
|
||||||
|
if len(args) >= 2:
|
||||||
|
log_type = args[1].lower()
|
||||||
|
if len(args) >= 3:
|
||||||
|
try:
|
||||||
|
limit = int(args[2])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from agromatrix_tools.tool_farmos_read import _farmos_read_logs_impl
|
||||||
|
result_text = _farmos_read_logs_impl(log_type=log_type, limit=limit)
|
||||||
|
except Exception as exc:
|
||||||
|
result_text = f"FarmOS logs: внутрішня помилка ({type(exc).__name__})."
|
||||||
|
_tlog_farmos_cmd("farmos_logs_cmd", ok=not result_text.startswith("FarmOS:"),
|
||||||
|
sub="logs", log_type=log_type)
|
||||||
|
return _wrap(result_text)
|
||||||
|
|
||||||
|
# ── /farmos або /farmos status ────────────────────────────────────────────
|
||||||
|
if sub not in ("status",):
|
||||||
|
return _wrap(
|
||||||
|
"Команда farmos: підтримується /farmos, /farmos status або /farmos logs [log_type] [limit]."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from agromatrix_tools.tool_farmos_read import _farmos_ping_impl
|
||||||
|
status_text = _farmos_ping_impl()
|
||||||
|
except Exception as exc:
|
||||||
|
status_text = f"FarmOS status недоступний: внутрішня помилка виконання ({type(exc).__name__})."
|
||||||
|
|
||||||
|
ok = status_text.startswith("FarmOS доступний")
|
||||||
|
_tlog_farmos_cmd("farmos_status_cmd", ok=ok, sub="status")
|
||||||
|
return _wrap(status_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _tlog_farmos_cmd(event: str, ok: bool, sub: str = "", log_type: str = "") -> None:
|
||||||
|
"""PII-safe telemetry для farmos operator commands."""
|
||||||
|
try:
|
||||||
|
import logging as _logging
|
||||||
|
extra = f" sub={sub}" if sub else ""
|
||||||
|
extra += f" log_type={log_type}" if log_type else ""
|
||||||
|
_logging.getLogger(__name__).info(
|
||||||
|
"AGX_STEPAN_METRIC %s ok=%s%s", event, ok, extra,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── v4.6: /farm state — FarmOS → Farm State Snapshot ─────────────────────────
|
||||||
|
#
|
||||||
|
# Smoke checklist (manual):
|
||||||
|
# /farmos → FarmOS ping still works (regression)
|
||||||
|
# /farm → "підтримується тільки /farm state"
|
||||||
|
# /farm state (no env) → "FarmOS не налаштований..."
|
||||||
|
# /farm state (env ok) → snapshot text, logs show farm_state_cmd_saved ok=true
|
||||||
|
# /farm foo → unknown subcommand message
|
||||||
|
|
||||||
|
_FARM_STATE_ASSET_QUERIES: list[tuple[str, int]] = [
|
||||||
|
("asset_land", 10),
|
||||||
|
("asset_plant", 10),
|
||||||
|
("asset_equipment", 5),
|
||||||
|
]
|
||||||
|
|
||||||
|
_FARM_STATE_LABELS: dict[str, str] = {
|
||||||
|
"asset_land": "Поля",
|
||||||
|
"asset_plant": "Культури/рослини",
|
||||||
|
"asset_equipment": "Техніка",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Максимальна довжина snapshot-тексту
|
||||||
|
_FARM_STATE_MAX_CHARS = 900
|
||||||
|
|
||||||
|
|
||||||
|
def handle_farm_command(args: list, chat_id: str | None = None) -> dict:
|
||||||
|
"""
|
||||||
|
/farm state — збирає snapshot активів з FarmOS і зберігає в memory-service.
|
||||||
|
Fail-closed: будь-яка помилка → зрозумілий текст, не кидає.
|
||||||
|
|
||||||
|
Smoke checklist (manual):
|
||||||
|
/farm → only /farm state supported
|
||||||
|
/farm state (no env) → config missing message
|
||||||
|
/farm state (env) → snapshot + saved to memory
|
||||||
|
"""
|
||||||
|
sub = args[0].lower() if args else ""
|
||||||
|
|
||||||
|
if sub != "state":
|
||||||
|
return _wrap(
|
||||||
|
"Команда farm: підтримується тільки /farm state."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _handle_farm_state(chat_id=chat_id)
|
||||||
|
except Exception as exc:
|
||||||
|
import logging as _logging
|
||||||
|
_logging.getLogger(__name__).warning(
|
||||||
|
"handle_farm_command unexpected error: %s", exc
|
||||||
|
)
|
||||||
|
return _wrap("Farm state: не вдалося отримати дані (перевір FarmOS / мережу).")
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_farm_state(chat_id: str | None) -> dict:
|
||||||
|
"""Ядро логіки /farm state. Викликається тільки з handle_farm_command."""
|
||||||
|
import logging as _logging
|
||||||
|
_log = _logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_log.info("AGX_STEPAN_METRIC farm_state_cmd_started chat_id=h:%s",
|
||||||
|
str(chat_id or "")[:6])
|
||||||
|
|
||||||
|
# ── Крок 1: перевірка FarmOS доступності ─────────────────────────────────
|
||||||
|
try:
|
||||||
|
from agromatrix_tools.tool_farmos_read import _farmos_ping_impl
|
||||||
|
ping_result = _farmos_ping_impl()
|
||||||
|
except Exception as exc:
|
||||||
|
ping_result = f"FarmOS: помилка перевірки ({type(exc).__name__})."
|
||||||
|
|
||||||
|
if not ping_result.startswith("FarmOS доступний"):
|
||||||
|
return _wrap(ping_result)
|
||||||
|
|
||||||
|
# ── Крок 2: запит активів по трьох типах ─────────────────────────────────
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
tops: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from agromatrix_tools.tool_farmos_read import _farmos_search_assets_impl
|
||||||
|
except Exception:
|
||||||
|
return _wrap("Farm state: не вдалося отримати дані (agromatrix_tools недоступні).")
|
||||||
|
|
||||||
|
for asset_type, limit in _FARM_STATE_ASSET_QUERIES:
|
||||||
|
try:
|
||||||
|
raw = _farmos_search_assets_impl(asset_type=asset_type, limit=limit)
|
||||||
|
items = _parse_asset_lines(raw)
|
||||||
|
except Exception:
|
||||||
|
items = []
|
||||||
|
counts[asset_type] = len(items)
|
||||||
|
# top-3 labels (тільки назва, без UUID)
|
||||||
|
tops[asset_type] = [_label_from_asset_line(ln) for ln in items[:3]]
|
||||||
|
|
||||||
|
# ── Крок 3: формуємо snapshot-текст ──────────────────────────────────────
|
||||||
|
snapshot_text = _build_snapshot_text(counts, tops)
|
||||||
|
|
||||||
|
# ── Крок 4: зберігаємо в memory-service ──────────────────────────────────
|
||||||
|
save_ok = False
|
||||||
|
save_suffix = ""
|
||||||
|
if chat_id:
|
||||||
|
save_ok = _save_farm_state_snapshot(chat_id, counts, tops, snapshot_text)
|
||||||
|
if not save_ok:
|
||||||
|
save_suffix = "\n(Не зміг зберегти в пам'ять.)"
|
||||||
|
|
||||||
|
_log.info(
|
||||||
|
"AGX_STEPAN_METRIC farm_state_cmd_saved ok=%s reason=%s "
|
||||||
|
"land=%s plant=%s equip=%s",
|
||||||
|
save_ok,
|
||||||
|
"saved" if save_ok else ("no_chat_id" if not chat_id else "memory_error"),
|
||||||
|
counts.get("asset_land", 0),
|
||||||
|
counts.get("asset_plant", 0),
|
||||||
|
counts.get("asset_equipment", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
return _wrap(snapshot_text + save_suffix)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_asset_lines(raw: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
Парсить рядки виду "- label | type | id=xxxx" або "FarmOS: ...".
|
||||||
|
Повертає лише рядки що починаються з "- ".
|
||||||
|
Fail-safe: якщо raw не такого формату — повертає [].
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
lines = [ln.strip() for ln in str(raw).split("\n") if ln.strip().startswith("- ")]
|
||||||
|
return lines
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _label_from_asset_line(line: str) -> str:
|
||||||
|
"""
|
||||||
|
Витягує label з рядка "- label | type | id=xxxx".
|
||||||
|
Повертає перший сегмент після "- ", обрізаний.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = line.lstrip("- ").strip()
|
||||||
|
return content.split("|")[0].strip()
|
||||||
|
except Exception:
|
||||||
|
return line.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_snapshot_text(
|
||||||
|
counts: dict[str, int],
|
||||||
|
tops: dict[str, list[str]],
|
||||||
|
) -> str:
|
||||||
|
"""Формує human-readable snapshot ≤ _FARM_STATE_MAX_CHARS символів."""
|
||||||
|
total = sum(counts.values())
|
||||||
|
if total == 0:
|
||||||
|
return (
|
||||||
|
"FarmOS: немає даних по assets "
|
||||||
|
"(або типи відрізняються у вашій інстанції)."
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = ["Farm state (FarmOS):"]
|
||||||
|
for asset_type, label in _FARM_STATE_LABELS.items():
|
||||||
|
n = counts.get(asset_type, 0)
|
||||||
|
top = tops.get(asset_type, [])
|
||||||
|
if top:
|
||||||
|
top_str = ", ".join(top[:3])
|
||||||
|
lines.append(f"- {label}: {n} (топ: {top_str})")
|
||||||
|
else:
|
||||||
|
lines.append(f"- {label}: {n}")
|
||||||
|
|
||||||
|
text = "\n".join(lines)
|
||||||
|
# Детермінований hard cap
|
||||||
|
if len(text) > _FARM_STATE_MAX_CHARS:
|
||||||
|
text = text[:_FARM_STATE_MAX_CHARS].rsplit("\n", 1)[0]
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _save_farm_state_snapshot(
|
||||||
|
chat_id: str,
|
||||||
|
counts: dict[str, int],
|
||||||
|
tops: dict[str, list[str]],
|
||||||
|
snapshot_text: str,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Зберігає snapshot у memory-service під ключем
|
||||||
|
farm_state:agromatrix:chat:{chat_id}.
|
||||||
|
Fail-closed: повертає True/False, не кидає.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
fact_key = f"farm_state:agromatrix:chat:{chat_id}"
|
||||||
|
# synthetic_uid — той самий паттерн що в memory_manager.py
|
||||||
|
synthetic_uid = f"farm:{chat_id}"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"_version": 1,
|
||||||
|
"source": "farmos",
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"counts": counts,
|
||||||
|
"top": tops,
|
||||||
|
"text": snapshot_text,
|
||||||
|
}
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
mem_url = os.getenv(
|
||||||
|
"AGX_MEMORY_SERVICE_URL",
|
||||||
|
os.getenv("MEMORY_SERVICE_URL", "http://memory-service:8000"),
|
||||||
|
)
|
||||||
|
resp = httpx.post(
|
||||||
|
f"{mem_url}/facts/upsert",
|
||||||
|
json={
|
||||||
|
"user_id": synthetic_uid,
|
||||||
|
"fact_key": fact_key,
|
||||||
|
"fact_value_json": payload,
|
||||||
|
},
|
||||||
|
timeout=3.0,
|
||||||
|
)
|
||||||
|
return resp.status_code in (200, 201)
|
||||||
|
except Exception as exc:
|
||||||
|
import logging as _logging
|
||||||
|
_logging.getLogger(__name__).debug(
|
||||||
|
"farm_state snapshot save failed: %s", exc
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from crewai import Crew, Task
|
from crewai import Crew, Task
|
||||||
@@ -14,6 +16,172 @@ from crews.agromatrix_crew.audit import audit_event, new_trace
|
|||||||
from agromatrix_tools import tool_dictionary
|
from agromatrix_tools import tool_dictionary
|
||||||
from agromatrix_tools import tool_operation_plan
|
from agromatrix_tools import tool_operation_plan
|
||||||
from crews.agromatrix_crew.operator_commands import route_operator_command, route_operator_text
|
from crews.agromatrix_crew.operator_commands import route_operator_command, route_operator_text
|
||||||
|
from crews.agromatrix_crew.memory_manager import (
|
||||||
|
load_user_profile,
|
||||||
|
save_user_profile,
|
||||||
|
load_farm_profile,
|
||||||
|
save_farm_profile,
|
||||||
|
update_profile_if_needed,
|
||||||
|
)
|
||||||
|
from crews.agromatrix_crew.style_adapter import adapt_response_style, build_style_prefix
|
||||||
|
from crews.agromatrix_crew.reflection_engine import reflect_on_response
|
||||||
|
from crews.agromatrix_crew.light_reply import build_light_reply, classify_light_event
|
||||||
|
from crews.agromatrix_crew.depth_classifier import classify_depth
|
||||||
|
from crews.agromatrix_crew.telemetry import tlog
|
||||||
|
from crews.agromatrix_crew.session_context import (
|
||||||
|
load_session,
|
||||||
|
update_session,
|
||||||
|
is_doc_focus_active,
|
||||||
|
is_doc_focus_cooldown_active,
|
||||||
|
DOC_FOCUS_TTL,
|
||||||
|
DOC_FOCUS_COOLDOWN_S,
|
||||||
|
)
|
||||||
|
from crews.agromatrix_crew.proactivity import maybe_add_proactivity
|
||||||
|
from crews.agromatrix_crew.doc_facts import (
|
||||||
|
extract_doc_facts,
|
||||||
|
merge_doc_facts,
|
||||||
|
can_answer_from_facts,
|
||||||
|
compute_scenario,
|
||||||
|
format_facts_as_text,
|
||||||
|
extract_fact_claims,
|
||||||
|
build_self_correction,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Doc Focus Gate helpers (v3.5 / v3.6 / v3.7) ────────────────────────────
|
||||||
|
# Логіка винесена в doc_focus.py (без залежностей від crewai/agromatrix_tools)
|
||||||
|
from crews.agromatrix_crew.doc_focus import ( # noqa: E402
|
||||||
|
_is_doc_question,
|
||||||
|
_detect_domain,
|
||||||
|
detect_context_signals,
|
||||||
|
build_mode_clarifier,
|
||||||
|
)
|
||||||
|
from crews.agromatrix_crew.farm_state import ( # noqa: E402
|
||||||
|
detect_farm_state_updates,
|
||||||
|
update_farm_state,
|
||||||
|
build_farm_state_prefix,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── v4.2: Vision → Agronomy Bridge ──────────────────────────────────────────
|
||||||
|
# Fail-safe lazy import: vision_guard живе в gateway-bot/, не в crews/.
|
||||||
|
# Якщо модуль недоступний (наприклад, юніт-тести без gateway-bot) — мовчки
|
||||||
|
# пропускаємо; вся логіка нижче захищена try/except.
|
||||||
|
def _vb_get_vision_lock(agent_id: str, chat_id: str) -> dict:
|
||||||
|
try:
|
||||||
|
from vision_guard import get_vision_lock as _gvl
|
||||||
|
return _gvl(agent_id, chat_id) or {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── v4.7: FarmOS Farm State Bridge ───────────────────────────────────────────
|
||||||
|
# Читаємо збережений /farm state snapshot з memory-service.
|
||||||
|
# Fail-closed: timeout 2s, не кидає виняток, не блокує відповідь.
|
||||||
|
# Максимальний вік snapshot: 24h (86400s).
|
||||||
|
_FARM_STATE_SNAPSHOT_TTL_S = 86400.0
|
||||||
|
|
||||||
|
|
||||||
|
def _load_farm_state_snapshot(chat_id: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Завантажує snapshot тексту з memory-service (ключ farm_state:agromatrix:chat:{chat_id}).
|
||||||
|
Повертає текст якщо snapshot є і не старший за 24h, інакше None.
|
||||||
|
Fail-closed: будь-яка помилка → None.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
import json as _json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
mem_url = os.getenv(
|
||||||
|
"AGX_MEMORY_SERVICE_URL",
|
||||||
|
os.getenv("MEMORY_SERVICE_URL", "http://memory-service:8000"),
|
||||||
|
)
|
||||||
|
fact_key = f"farm_state:agromatrix:chat:{chat_id}"
|
||||||
|
synthetic_uid = f"farm:{chat_id}"
|
||||||
|
|
||||||
|
# memory-service API: GET /facts/{fact_key}?user_id=...
|
||||||
|
# (не /facts/get — той endpoint не існує)
|
||||||
|
resp = httpx.get(
|
||||||
|
f"{mem_url}/facts/{fact_key}",
|
||||||
|
params={"user_id": synthetic_uid},
|
||||||
|
timeout=2.0,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
# fact_value_json може повертатися як рядок або dict
|
||||||
|
val = data.get("fact_value_json") or data.get("fact_value")
|
||||||
|
if isinstance(val, str):
|
||||||
|
try:
|
||||||
|
val = _json.loads(val)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not isinstance(val, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Перевірка TTL: generated_at має бути не старішим за 24h
|
||||||
|
generated_at = val.get("generated_at", "")
|
||||||
|
if generated_at:
|
||||||
|
try:
|
||||||
|
ts = datetime.fromisoformat(generated_at.replace("Z", "+00:00"))
|
||||||
|
age_s = (datetime.now(timezone.utc) - ts).total_seconds()
|
||||||
|
if age_s > _FARM_STATE_SNAPSHOT_TTL_S:
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
pass # якщо не можемо перевірити вік — все одно повертаємо snapshot
|
||||||
|
|
||||||
|
text = val.get("text", "").strip()
|
||||||
|
return text if text else None
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Light / Deep activation layer — реалізація у depth_classifier.py
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _stepan_light_response(text: str, stepan, trace: dict, user_profile: dict | None) -> str:
|
||||||
|
"""
|
||||||
|
Відповідь Степана у Light mode.
|
||||||
|
|
||||||
|
Спочатку намагається побудувати детерміновану (seeded) відповідь без LLM —
|
||||||
|
для чітких greeting/thanks/ack/short_followup подій.
|
||||||
|
Якщо без-LLM відповідь є — повертає її одразу (нульова затримка).
|
||||||
|
Інакше — LLM одним агентом без делегування.
|
||||||
|
Логує crew_launch=false.
|
||||||
|
"""
|
||||||
|
logger.info("crew_launch=false depth=light")
|
||||||
|
|
||||||
|
# Fast path: no LLM needed for clear social events
|
||||||
|
fast_reply = build_light_reply(text, user_profile)
|
||||||
|
if fast_reply:
|
||||||
|
audit_event({**trace, 'agent': 'stepan', 'action': 'light_nollm'})
|
||||||
|
return fast_reply
|
||||||
|
|
||||||
|
# LLM path: single Stepan task, no sub-agents
|
||||||
|
task = Task(
|
||||||
|
description=(
|
||||||
|
f"Повідомлення від користувача: {text}\n\n"
|
||||||
|
"Дай коротку, природну, людську відповідь. "
|
||||||
|
"Не запускай жодних операційних інструментів. "
|
||||||
|
"Не форматуй як JSON. "
|
||||||
|
"Не починай з 'Звісно', 'Чудово', 'Дозвольте'. "
|
||||||
|
"Одне питання максимум, якщо потрібно."
|
||||||
|
),
|
||||||
|
expected_output="Коротка розмовна відповідь українською, 1–4 речення.",
|
||||||
|
agent=stepan,
|
||||||
|
)
|
||||||
|
crew = Crew(agents=[stepan], tasks=[task], verbose=False)
|
||||||
|
result = crew.kickoff()
|
||||||
|
audit_event({**trace, 'agent': 'stepan', 'action': 'light_response'})
|
||||||
|
return adapt_response_style(str(result), user_profile)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def farmos_ui_hint():
|
def farmos_ui_hint():
|
||||||
@@ -97,7 +265,22 @@ def run_task_with_retry(agent, description: str, trace_id: str, max_retries: int
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def handle_message(text: str, user_id: str = '', chat_id: str = '', trace_id: str = '', ops_mode: bool = False, last_pending_list: list | None = None) -> str:
|
def handle_message(
|
||||||
|
text: str,
|
||||||
|
user_id: str = '',
|
||||||
|
chat_id: str = '',
|
||||||
|
trace_id: str = '',
|
||||||
|
ops_mode: bool = False,
|
||||||
|
last_pending_list: list | None = None,
|
||||||
|
# injected by memory layer (Промт 3); None until that layer is wired
|
||||||
|
user_profile: dict | None = None,
|
||||||
|
farm_profile: dict | None = None,
|
||||||
|
has_doc_context: bool = False,
|
||||||
|
# Doc Bridge (v3.1): короткий контекст активного документа з gateway
|
||||||
|
doc_context: dict | None = None,
|
||||||
|
# Chat History Bridge (v3.2): текст переписки з memory-service (до 40 повідомлень)
|
||||||
|
chat_history: str = '',
|
||||||
|
) -> str:
|
||||||
trace = new_trace(text)
|
trace = new_trace(text)
|
||||||
if trace_id:
|
if trace_id:
|
||||||
trace['trace_id'] = trace_id
|
trace['trace_id'] = trace_id
|
||||||
@@ -107,7 +290,14 @@ def handle_message(text: str, user_id: str = '', chat_id: str = '', trace_id: st
|
|||||||
os.environ['AGX_CHAT_ID'] = str(chat_id)
|
os.environ['AGX_CHAT_ID'] = str(chat_id)
|
||||||
os.environ['AGX_OPS_MODE'] = '1' if ops_mode else '0'
|
os.environ['AGX_OPS_MODE'] = '1' if ops_mode else '0'
|
||||||
|
|
||||||
# operator commands
|
# Load profiles (fail-safe: always returns a valid dict)
|
||||||
|
if user_profile is None and user_id:
|
||||||
|
user_profile = load_user_profile(str(user_id))
|
||||||
|
if farm_profile is None and chat_id:
|
||||||
|
# v2.8: pass user_id for lazy legacy migration
|
||||||
|
farm_profile = load_farm_profile(str(chat_id), user_id=str(user_id) if user_id else None)
|
||||||
|
|
||||||
|
# operator commands (unchanged routing)
|
||||||
if text.strip().startswith('/'):
|
if text.strip().startswith('/'):
|
||||||
op_res = route_operator_command(text, str(user_id), str(chat_id))
|
op_res = route_operator_command(text, str(user_id), str(chat_id))
|
||||||
if op_res:
|
if op_res:
|
||||||
@@ -117,100 +307,621 @@ def handle_message(text: str, user_id: str = '', chat_id: str = '', trace_id: st
|
|||||||
if op_res:
|
if op_res:
|
||||||
return json.dumps(op_res, ensure_ascii=False)
|
return json.dumps(op_res, ensure_ascii=False)
|
||||||
|
|
||||||
stepan = build_stepan()
|
# Load session context (v3: TTL 15 min, in-memory)
|
||||||
ops = build_operations()
|
session = load_session(str(chat_id))
|
||||||
iot = build_iot()
|
|
||||||
platform = build_platform()
|
|
||||||
spreadsheet = build_spreadsheet()
|
|
||||||
sustainability = build_sustainability()
|
|
||||||
|
|
||||||
audit_event({**trace, 'agent': 'stepan', 'action': 'intake'})
|
# ── v4: FARM STATE UPDATE ────────────────────────────────────────────────
|
||||||
|
# Оновлюємо farm_state до будь-якої іншої логіки (ізольовано, fail-safe).
|
||||||
|
# Не впливає на doc_mode, depth classifier, memory_manager.
|
||||||
|
_farm_updates = detect_farm_state_updates(text or "")
|
||||||
|
if _farm_updates:
|
||||||
|
update_farm_state(session, _farm_updates)
|
||||||
|
tlog(logger, "farm_state_updated",
|
||||||
|
chat_id=str(chat_id),
|
||||||
|
fields=",".join(_farm_updates.keys()))
|
||||||
|
|
||||||
# Preflight normalization
|
# ── v4.2: VISION → AGRONOMY BRIDGE ──────────────────────────────────────
|
||||||
norm = tool_dictionary.normalize_from_text(text, trace_id=trace['trace_id'], source='telegram')
|
# Читаємо vision lock (per agent_id:chat_id) і зберігаємо label у session.
|
||||||
pending = [item for cat in norm.values() for item in cat if item.get('status') == 'pending']
|
# Тільки якщо lock свіжий (TTL перевіряє get_vision_lock всередині).
|
||||||
if pending:
|
# User override ("це соняшник") вже записаний у lock.user_label.
|
||||||
lines = ["=== PENDING TERMS (Stepan) ==="]
|
# Fail-safe: будь-яка помилка → пропускаємо.
|
||||||
for item in pending:
|
_agent_id_str = os.getenv("AGX_AGENT_ID", "agromatrix")
|
||||||
lines.append(f"- {item.get('term')}: {item.get('suggestions', [])[:3]}")
|
try:
|
||||||
lines.append("\nБудь ласка, уточніть невідомі терміни. Після підтвердження я продовжу.")
|
_vb_lock = _vb_get_vision_lock(_agent_id_str, str(chat_id))
|
||||||
return "\n".join(lines)
|
if _vb_lock:
|
||||||
|
_vb_label = (_vb_lock.get("user_label") or _vb_lock.get("label") or "").strip()
|
||||||
|
if _vb_label:
|
||||||
|
session["vision_last_label"] = _vb_label
|
||||||
|
tlog(logger, "vision_bridge_label_loaded",
|
||||||
|
chat_id=str(chat_id), label=_vb_label)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── v4.2: USER TEXT OVERRIDE for vision_last_label ───────────────────────
|
||||||
|
# Якщо юзер явно змінює культуру текстом ("тепер це кукурудза") →
|
||||||
|
# перезаписуємо vision_last_label безпосередньо.
|
||||||
|
# Використовуємо той самий detect_user_override з vision_guard (fail-safe).
|
||||||
|
if text:
|
||||||
|
try:
|
||||||
|
from vision_guard import detect_user_override as _vb_detect_override
|
||||||
|
_vb_text_label = _vb_detect_override(text)
|
||||||
|
if _vb_text_label:
|
||||||
|
session["vision_last_label"] = _vb_text_label
|
||||||
|
tlog(logger, "vision_bridge_text_override",
|
||||||
|
chat_id=str(chat_id), label=_vb_text_label)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── DOC CONTEXT FALLBACK (v3.3) ──────────────────────────────────────────
|
||||||
|
# Якщо gateway не передав doc_context — пробуємо підтягнути chat-scoped з memory.
|
||||||
|
# Це дозволяє Stepan бачити документ навіть якщо gateway не синхронізований.
|
||||||
|
if not doc_context and chat_id:
|
||||||
|
try:
|
||||||
|
import httpx as _httpx
|
||||||
|
import os as _os
|
||||||
|
_mem_url = _os.getenv("MEMORY_SERVICE_URL", "http://memory-service:8000")
|
||||||
|
_agent_id_env = _os.getenv("AGX_AGENT_ID", "agromatrix")
|
||||||
|
_aid = _agent_id_env.lower()
|
||||||
|
_fact_user = f"chat:{_aid}:{chat_id}"
|
||||||
|
_fact_key = f"doc_context_chat:{_aid}:{chat_id}"
|
||||||
|
with _httpx.Client(timeout=3.0) as _hc:
|
||||||
|
_r = _hc.post(
|
||||||
|
f"{_mem_url}/facts/get",
|
||||||
|
json={"user_id": _fact_user, "fact_key": _fact_key},
|
||||||
|
)
|
||||||
|
if _r.status_code == 200:
|
||||||
|
_fdata = _r.json()
|
||||||
|
_fval = _fdata.get("fact_value_json") or {}
|
||||||
|
if isinstance(_fval, str):
|
||||||
|
import json as _json
|
||||||
|
_fval = _json.loads(_fval)
|
||||||
|
if _fval.get("doc_id") or _fval.get("file_unique_id"):
|
||||||
|
doc_context = _fval
|
||||||
|
has_doc_context = True
|
||||||
|
tlog(logger, "doc_context_fallback_loaded",
|
||||||
|
chat_id=str(chat_id),
|
||||||
|
doc_id=str(_fval.get("doc_id", ""))[:16])
|
||||||
|
except Exception as _fbe:
|
||||||
|
logger.debug("Doc context fallback failed (non-blocking): %s", _fbe)
|
||||||
|
|
||||||
|
# ── DOC ANCHOR RESET (v3.3) ──────────────────────────────────────────────
|
||||||
|
# Якщо doc_id змінився — скидаємо doc_facts і fact_claims попереднього документу.
|
||||||
|
# Fix D: active_doc_id пріоритетний — він явно фіксується при upload (навіть без тексту),
|
||||||
|
# тому перший text-запит після upload одразу має правильний anchor.
|
||||||
|
_current_doc_id: str | None = None
|
||||||
|
if doc_context:
|
||||||
|
_current_doc_id = (
|
||||||
|
doc_context.get("active_doc_id")
|
||||||
|
or doc_context.get("doc_id")
|
||||||
|
or doc_context.get("file_unique_id")
|
||||||
|
or None
|
||||||
|
)
|
||||||
|
if _current_doc_id:
|
||||||
|
_prev_doc_id = session.get("active_doc_id")
|
||||||
|
if _prev_doc_id and _prev_doc_id != _current_doc_id:
|
||||||
|
session["doc_facts"] = {}
|
||||||
|
session["fact_claims"] = []
|
||||||
|
tlog(logger, "doc_anchor_reset",
|
||||||
|
old=str(_prev_doc_id)[:16], new=str(_current_doc_id)[:16])
|
||||||
|
|
||||||
|
# ── DOC FOCUS GATE (v3.5 / v3.6) ────────────────────────────────────────
|
||||||
|
import time as _time_mod
|
||||||
|
_now_ts = _time_mod.time()
|
||||||
|
|
||||||
|
# TTL auto-expire (самозцілювальний)
|
||||||
|
if session.get("doc_focus") and not is_doc_focus_active(session, _now_ts):
|
||||||
|
_expired_age = round(_now_ts - (session.get("doc_focus_ts") or 0.0))
|
||||||
|
session["doc_focus"] = False
|
||||||
|
session["doc_focus_ts"] = 0.0
|
||||||
|
tlog(logger, "doc_focus_expired", chat_id=str(chat_id),
|
||||||
|
ttl_s=_expired_age, last_doc=str(session.get("active_doc_id", ""))[:16])
|
||||||
|
|
||||||
|
_signals = detect_context_signals(text)
|
||||||
|
_domain = _detect_domain(text, logger=logger)
|
||||||
|
_focus_active = is_doc_focus_active(session, _now_ts)
|
||||||
|
_cooldown_active = is_doc_focus_cooldown_active(session, _now_ts)
|
||||||
|
|
||||||
|
# Auto-clear doc_focus + встановити cooldown при зміні домену (web/vision)
|
||||||
|
_df_cooldown_until_update: float | None = None
|
||||||
|
if _focus_active and _domain in ("vision", "web"):
|
||||||
|
session["doc_focus"] = False
|
||||||
|
session["doc_focus_ts"] = 0.0
|
||||||
|
_focus_active = False
|
||||||
|
_cooldown_until = _now_ts + DOC_FOCUS_COOLDOWN_S
|
||||||
|
session["doc_focus_cooldown_until"] = _cooldown_until
|
||||||
|
_cooldown_active = True
|
||||||
|
_df_cooldown_until_update = _cooldown_until
|
||||||
|
tlog(logger, "doc_focus_cleared", chat_id=str(chat_id), reason=_domain)
|
||||||
|
tlog(logger, "doc_focus_cooldown_set", chat_id=str(chat_id),
|
||||||
|
seconds=int(DOC_FOCUS_COOLDOWN_S), reason=_domain)
|
||||||
|
|
||||||
|
# Визначаємо context_mode з gating-правилами (v3.6)
|
||||||
|
_context_mode: str
|
||||||
|
_doc_denied_reason: str | None = None
|
||||||
|
|
||||||
|
if _domain in ("doc",):
|
||||||
|
_is_explicit = _signals["has_explicit_doc_token"]
|
||||||
|
|
||||||
|
# Rule 1: Cooldown блокує implicit doc (не explicit)
|
||||||
|
if _cooldown_active and not _is_explicit:
|
||||||
|
_context_mode = "general"
|
||||||
|
_doc_denied_reason = "cooldown"
|
||||||
|
_ttl_left = int((session.get("doc_focus_cooldown_until") or 0.0) - _now_ts)
|
||||||
|
tlog(logger, "doc_mode_denied", chat_id=str(chat_id),
|
||||||
|
reason="cooldown", ttl_left=_ttl_left)
|
||||||
|
|
||||||
|
# Rule 2: Без explicit — дозволити doc тільки якщо є fact-сигнал або факти покривають
|
||||||
|
elif not _is_explicit:
|
||||||
|
_session_facts_gate = session.get("doc_facts") or {}
|
||||||
|
try:
|
||||||
|
from crews.agromatrix_crew.doc_facts import can_answer_from_facts as _cafg
|
||||||
|
_can_use_facts, _ = _cafg(text, _session_facts_gate)
|
||||||
|
except Exception:
|
||||||
|
_can_use_facts = False
|
||||||
|
|
||||||
|
if _signals["has_fact_signal"] or _can_use_facts:
|
||||||
|
# Дозволяємо doc-mode через факт-сигнал
|
||||||
|
_context_mode = "doc"
|
||||||
|
else:
|
||||||
|
_context_mode = "general"
|
||||||
|
_doc_denied_reason = "no_fact_signal"
|
||||||
|
tlog(logger, "doc_mode_denied", chat_id=str(chat_id),
|
||||||
|
reason="no_fact_signal",
|
||||||
|
has_facts=bool(_session_facts_gate))
|
||||||
|
|
||||||
|
# Rule 3: Explicit doc-токен завжди дозволяє doc (навіть при cooldown)
|
||||||
|
else:
|
||||||
|
_context_mode = "doc"
|
||||||
|
|
||||||
|
elif _domain == "general" and (_focus_active or _current_doc_id) and _signals["has_fact_signal"]:
|
||||||
|
# Факт-сигнал без doc-домену + active doc → дозволяємо doc якщо фокус живий
|
||||||
|
_context_mode = "doc" if _focus_active else "general"
|
||||||
|
else:
|
||||||
|
_context_mode = "general"
|
||||||
|
|
||||||
|
# Активуємо/продовжуємо doc_focus при успішному doc-mode
|
||||||
|
if _context_mode == "doc" and _current_doc_id:
|
||||||
|
if not _focus_active:
|
||||||
|
session["doc_focus"] = True
|
||||||
|
session["doc_focus_ts"] = _now_ts
|
||||||
|
_focus_active = True
|
||||||
|
tlog(logger, "doc_focus_set", chat_id=str(chat_id),
|
||||||
|
reason="doc_question_reactivated", doc_id=str(_current_doc_id)[:16])
|
||||||
|
|
||||||
|
tlog(logger, "context_mode", chat_id=str(chat_id),
|
||||||
|
mode=_context_mode, domain=_domain, focus=_focus_active,
|
||||||
|
cooldown=_cooldown_active)
|
||||||
|
|
||||||
|
# v3.6: Якщо doc-mode заблокований — повернути clarifier одразу (без LLM)
|
||||||
|
if _doc_denied_reason in ("cooldown", "no_fact_signal") and _domain == "doc":
|
||||||
|
_clarifier = build_mode_clarifier(text)
|
||||||
|
update_session(str(chat_id), text, depth="light", agents=[], last_question=None)
|
||||||
|
return _clarifier
|
||||||
|
|
||||||
|
# ── PHOTO CONTEXT GATE (v3.5 fix) ───────────────────────────────────────
|
||||||
|
# Якщо щойно було фото (< 120с) і запит короткий (<=3 слів) і без explicit doc —
|
||||||
|
# відповідаємо коротким уточненням замість light/general відповіді без контексту.
|
||||||
|
_last_photo_ts = float(session.get("last_photo_ts") or 0.0)
|
||||||
|
_photo_ctx_ttl = 120.0
|
||||||
|
_photo_just_sent = (_now_ts - _last_photo_ts) < _photo_ctx_ttl and _last_photo_ts > 0
|
||||||
|
if _photo_just_sent and len(text.split()) <= 3 and not _signals.get("has_explicit_doc_token"):
|
||||||
|
_photo_age = int(_now_ts - _last_photo_ts)
|
||||||
|
tlog(logger, "photo_context_gate", chat_id=str(chat_id),
|
||||||
|
age_s=_photo_age, words=len(text.split()))
|
||||||
|
update_session(str(chat_id), text, depth="light", agents=[], last_question=None)
|
||||||
|
return "Що саме хочеш дізнатися про фото?"
|
||||||
|
|
||||||
|
# ── CONFIRMATION GATE (v3.1) ────────────────────────────────────────────
|
||||||
|
# Якщо є pending_action у сесії і повідомлення — підтвердження,
|
||||||
|
# підставляємо контекст попереднього кроку і йдемо в deep.
|
||||||
|
_CONFIRMATION_WORDS = re.compile(
|
||||||
|
r"^(так|зроби|ок|ok|окей|погоджуюсь|погодився|роби|давай|підтверджую|yes|go|sure)[\W]*$",
|
||||||
|
re.IGNORECASE | re.UNICODE,
|
||||||
|
)
|
||||||
|
pending_action = session.get("pending_action")
|
||||||
|
if pending_action and _CONFIRMATION_WORDS.match(text.strip()):
|
||||||
|
tlog(logger, "confirmation_consumed", chat_id=str(chat_id),
|
||||||
|
intent=pending_action.get("intent"))
|
||||||
|
# Розширюємо контекст: використовуємо збережений intent і what_to_do_next
|
||||||
|
text = pending_action.get("what_to_do_next") or text
|
||||||
|
has_doc_context = has_doc_context or bool(pending_action.get("doc_context"))
|
||||||
|
|
||||||
|
# ── DOC BRIDGE (v3.1/v3.2 / v3.5) ──────────────────────────────────────
|
||||||
|
# PROMPT B: doc_context підмішуємо в промпт ТІЛЬКИ якщо context_mode == "doc".
|
||||||
|
# Якщо "general" — документ є але мовчимо про нього (не нав'язуємо "у цьому звіті").
|
||||||
|
_doc_summary_snippet: str = ""
|
||||||
|
if doc_context and _context_mode == "doc":
|
||||||
|
doc_id = doc_context.get("doc_id") or doc_context.get("id", "")
|
||||||
|
doc_title = doc_context.get("title") or doc_context.get("filename", "")
|
||||||
|
doc_summary = doc_context.get("extracted_summary") or doc_context.get("summary", "")
|
||||||
|
has_doc_context = True
|
||||||
|
tlog(logger, "doc_context_used", chat_id=str(chat_id), doc_id=doc_id,
|
||||||
|
has_content=bool(doc_summary))
|
||||||
|
elif doc_context and _context_mode == "general":
|
||||||
|
# Документ є, але поточний запит не про нього — не підмішуємо
|
||||||
|
doc_id = doc_context.get("doc_id") or ""
|
||||||
|
doc_title = ""
|
||||||
|
doc_summary = ""
|
||||||
|
tlog(logger, "doc_context_suppressed", chat_id=str(chat_id),
|
||||||
|
reason="context_mode_general", doc_id=doc_id[:16] if doc_id else "")
|
||||||
|
if doc_context and _context_mode == "doc":
|
||||||
|
# Будуємо snippet для deep-mode промпту (тільки в doc mode)
|
||||||
|
parts = []
|
||||||
|
if doc_title:
|
||||||
|
parts.append(f"=== ДОКУМЕНТ: «{doc_title}» ===")
|
||||||
|
if doc_summary:
|
||||||
|
# До 3000 символів — достатньо для xlsx з кількома листами
|
||||||
|
parts.append(f"ЗМІСТ ДОКУМЕНТА:\n{doc_summary[:3000]}")
|
||||||
|
parts.append("=== КІНЕЦЬ ДОКУМЕНТА ===")
|
||||||
|
elif doc_title:
|
||||||
|
# Fix E: є документ але summary порожній.
|
||||||
|
# Якщо file_id відомий — витяг можливий; НЕ просимо "надіслати ще раз".
|
||||||
|
_doc_file_id = doc_context.get("file_id") or doc_context.get("file_unique_id") or ""
|
||||||
|
if _doc_file_id:
|
||||||
|
parts.append(
|
||||||
|
f"(Вміст «{doc_title}» ще не витягнутий у цій сесії — "
|
||||||
|
f"але файл є. Перевір ІСТОРІЮ ДІАЛОГУ нижче: там можуть бути "
|
||||||
|
f"попередні відповіді про цей документ. Якщо в history нічого — "
|
||||||
|
f"відповідай: 'Зараз витягую дані — дай секунду.' і запропонуй "
|
||||||
|
f"1 конкретне уточнюючи питання.)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# file_id невідомий — тоді можна попросити надіслати знову
|
||||||
|
parts.append(
|
||||||
|
f"(Вміст «{doc_title}» недоступний. "
|
||||||
|
f"Перевір ІСТОРІЮ ДІАЛОГУ — там можуть бути попередні відповіді. "
|
||||||
|
f"Якщо в history нічого — попроси надіслати файл ще раз, одним реченням.)"
|
||||||
|
)
|
||||||
|
_doc_summary_snippet = "\n".join(parts)
|
||||||
|
|
||||||
|
# Light / Deep classification
|
||||||
|
last_topic = (user_profile or {}).get('last_topic')
|
||||||
|
depth = classify_depth(
|
||||||
|
text,
|
||||||
|
has_doc_context=has_doc_context,
|
||||||
|
last_topic=last_topic,
|
||||||
|
user_profile=user_profile,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
audit_event({**trace, 'agent': 'stepan', 'action': 'intake', 'depth': depth})
|
||||||
|
|
||||||
|
style_prefix = build_style_prefix(user_profile)
|
||||||
|
stepan = build_stepan(style_prefix=style_prefix)
|
||||||
|
|
||||||
|
# ── LIGHT MODE ────────────────────────────────────────────────────────────
|
||||||
|
if depth == "light":
|
||||||
|
tlog(logger, "crew_launch", launched=False, depth="light")
|
||||||
|
response = _stepan_light_response(text, stepan, trace, user_profile)
|
||||||
|
update_profile_if_needed(str(user_id), str(chat_id), text, response, intent=None, depth="light")
|
||||||
|
update_session(str(chat_id), text, depth="light", agents=[], last_question=None)
|
||||||
|
return response
|
||||||
|
|
||||||
|
# ── DEEP MODE ─────────────────────────────────────────────────────────────
|
||||||
|
tlog(logger, "crew_launch", launched=True, depth="deep", agents=["stepan"])
|
||||||
|
audit_event({**trace, 'agent': 'stepan', 'action': 'deep_single_agent'})
|
||||||
|
|
||||||
|
# ── FACT LOCK (v3.2) ────────────────────────────────────────────────────
|
||||||
|
# Якщо в сесії є зафіксовані числові факти — відповідаємо з кешу без RAG.
|
||||||
|
_session_facts: dict = session.get("doc_facts") or {}
|
||||||
|
if _session_facts:
|
||||||
|
_can_reuse, _reuse_keys = can_answer_from_facts(text, _session_facts)
|
||||||
|
if _can_reuse:
|
||||||
|
tlog(logger, "fact_reused", chat_id=str(chat_id),
|
||||||
|
keys=",".join(_reuse_keys))
|
||||||
|
# Спочатку перевіряємо сценарний розрахунок
|
||||||
|
_scen_ok, _scen_text = compute_scenario(text, _session_facts)
|
||||||
|
if _scen_ok:
|
||||||
|
_self_corr = build_self_correction(text, _session_facts, session, current_doc_id=_current_doc_id)
|
||||||
|
final = (_self_corr + _scen_text) if _self_corr else _scen_text
|
||||||
|
final = adapt_response_style(final, user_profile)
|
||||||
|
_new_claims = extract_fact_claims(final)
|
||||||
|
update_session(str(chat_id), text, depth="deep", agents=[],
|
||||||
|
last_question=None, doc_facts=_session_facts,
|
||||||
|
fact_claims=_new_claims, active_doc_id=_current_doc_id,
|
||||||
|
doc_focus=True, doc_focus_ts=_now_ts)
|
||||||
|
return final
|
||||||
|
# Без сценарію — форматуємо відомі факти
|
||||||
|
_facts_text = format_facts_as_text(
|
||||||
|
{k: _session_facts[k] for k in _reuse_keys if k in _session_facts}
|
||||||
|
)
|
||||||
|
_self_corr = build_self_correction(text, _session_facts, session, current_doc_id=_current_doc_id)
|
||||||
|
final = (_self_corr + _facts_text) if _self_corr else _facts_text
|
||||||
|
final = adapt_response_style(final, user_profile)
|
||||||
|
_new_claims = extract_fact_claims(final)
|
||||||
|
update_session(str(chat_id), text, depth="deep", agents=[],
|
||||||
|
last_question=None, doc_facts=_session_facts,
|
||||||
|
fact_claims=_new_claims, active_doc_id=_current_doc_id,
|
||||||
|
doc_focus=True, doc_focus_ts=_now_ts)
|
||||||
|
return final
|
||||||
|
|
||||||
|
# Preflight normalization — лише збагачення контексту, НЕ блокатор.
|
||||||
|
norm = {}
|
||||||
|
try:
|
||||||
|
norm = tool_dictionary.normalize_from_text(text, trace_id=trace['trace_id'], source='telegram')
|
||||||
|
except Exception as _ne:
|
||||||
|
logger.debug("normalize_from_text error (non-blocking): %s", _ne)
|
||||||
|
_all_pending = [item for cat in norm.values() for item in cat if item.get('status') == 'pending']
|
||||||
|
if _all_pending:
|
||||||
|
tlog(logger, "pending_terms_info", chat_id=str(chat_id), count=len(_all_pending))
|
||||||
|
|
||||||
intent = detect_intent(text)
|
intent = detect_intent(text)
|
||||||
pending_count = 0
|
|
||||||
if ops_mode:
|
|
||||||
try:
|
|
||||||
from agromatrix_tools import tool_dictionary_review as review
|
|
||||||
pending_count = review.stats().get('open', 0)
|
|
||||||
except Exception:
|
|
||||||
pending_count = 0
|
|
||||||
|
|
||||||
|
# Специфічні intent-и що потребують tool-виклику
|
||||||
if intent in ['plan_week', 'plan_day']:
|
if intent in ['plan_week', 'plan_day']:
|
||||||
plan_id = tool_operation_plan.create_plan({
|
try:
|
||||||
'scope': {
|
plan_id = tool_operation_plan.create_plan({
|
||||||
'field_ids': [i.get('normalized_id') for i in norm.get('fields', []) if i.get('status')=='ok'],
|
'scope': {
|
||||||
'crop_ids': [i.get('normalized_id') for i in norm.get('crops', []) if i.get('status')=='ok'],
|
'field_ids': [i.get('normalized_id') for i in norm.get('fields', []) if i.get('status')=='ok'],
|
||||||
'date_window': {'start': '', 'end': ''}
|
'crop_ids': [i.get('normalized_id') for i in norm.get('crops', []) if i.get('status')=='ok'],
|
||||||
},
|
'date_window': {'start': '', 'end': ''}
|
||||||
'tasks': []
|
},
|
||||||
}, trace_id=trace['trace_id'], source='telegram')
|
'tasks': []
|
||||||
return json.dumps({
|
}, trace_id=trace['trace_id'], source='telegram')
|
||||||
'status': 'ok',
|
return f"План створено: {plan_id}. Уточни дати та операції."
|
||||||
'summary': f'План створено: {plan_id}',
|
except Exception:
|
||||||
'artifacts': [],
|
pass
|
||||||
'tool_calls': [],
|
|
||||||
'next_actions': ['уточнити дати та операції'],
|
|
||||||
'pending_dictionary_items': pending_count if ops_mode else None
|
|
||||||
}, ensure_ascii=False)
|
|
||||||
|
|
||||||
if intent == 'show_critical_tomorrow':
|
# Будуємо промпт для Степана — з doc_context і chat_history
|
||||||
_ = tool_operation_plan.plan_dashboard({}, {})
|
task_parts = []
|
||||||
return json.dumps({
|
|
||||||
'status': 'ok',
|
|
||||||
'summary': 'Критичні задачі на завтра',
|
|
||||||
'artifacts': [],
|
|
||||||
'tool_calls': [],
|
|
||||||
'next_actions': [],
|
|
||||||
'pending_dictionary_items': pending_count if ops_mode else None
|
|
||||||
}, ensure_ascii=False)
|
|
||||||
|
|
||||||
if intent == 'plan_vs_fact':
|
# 0. v4: Farm State prefix (тільки не в doc і не web режимі)
|
||||||
_ = tool_operation_plan.plan_dashboard({}, {})
|
if _context_mode != "doc" and _domain not in ("web",):
|
||||||
return json.dumps({
|
_farm_prefix = build_farm_state_prefix(session)
|
||||||
'status': 'ok',
|
if _farm_prefix:
|
||||||
'summary': 'План/факт зведення',
|
task_parts.append(_farm_prefix)
|
||||||
'artifacts': [],
|
tlog(logger, "farm_state_injected", chat_id=str(chat_id),
|
||||||
'tool_calls': [],
|
crop=str((session.get("farm_state") or {}).get("current_crop", ""))[:20])
|
||||||
'next_actions': [],
|
|
||||||
'pending_dictionary_items': pending_count if ops_mode else None
|
|
||||||
}, ensure_ascii=False)
|
|
||||||
|
|
||||||
# general crew flow
|
# 0b. v4.2: Vision → Agronomy Bridge prefix
|
||||||
ops_out = run_task_with_retry(ops, "Оціни чи потрібні операційні записи або читання farmOS", trace['trace_id'])
|
# Додаємо контекст культури з останнього фото, якщо:
|
||||||
iot_out = run_task_with_retry(iot, "Оціни чи є потреба в даних ThingsBoard або NATS", trace['trace_id'])
|
# - НЕ doc mode (документ має пріоритет)
|
||||||
platform_out = run_task_with_retry(platform, "Перевір базовий статус сервісів/інтеграцій", trace['trace_id'])
|
# - НЕ web mode
|
||||||
sheet_out = run_task_with_retry(spreadsheet, "Якщо запит стосується таблиць — підготуй артефакти", trace['trace_id'])
|
# - vision_last_label є і не дублює farm_state (щоб не плутати Степана)
|
||||||
sustainability_out = run_task_with_retry(sustainability, "Якщо потрібні агрегації — дай read-only підсумки", trace['trace_id'])
|
if _context_mode != "doc" and _domain not in ("web",):
|
||||||
|
_vb_label_now = (session.get("vision_last_label") or "").strip()
|
||||||
|
_farm_crop = str((session.get("farm_state") or {}).get("current_crop", "")).strip()
|
||||||
|
if _vb_label_now and _vb_label_now != _farm_crop:
|
||||||
|
task_parts.append(
|
||||||
|
f"SYSTEM NOTE (не виводь це в відповідь): "
|
||||||
|
f"Культура з останнього фото — {_vb_label_now}. "
|
||||||
|
"Використовуй як контекст для агрономічних питань."
|
||||||
|
)
|
||||||
|
tlog(logger, "vision_bridge_injected",
|
||||||
|
chat_id=str(chat_id), label=_vb_label_now)
|
||||||
|
|
||||||
audit_event({**trace, 'agent': 'stepan', 'action': 'delegate', 'targets': ['ops','iot','platform','spreadsheet','sustainability']})
|
# 0c. v4.7: FarmOS Farm State Bridge
|
||||||
|
# Читаємо збережений /farm state snapshot з memory-service.
|
||||||
|
# Умови injection (аналогічно vision bridge):
|
||||||
|
# - НЕ doc mode
|
||||||
|
# - НЕ web domain
|
||||||
|
# - snapshot не старший за 24h
|
||||||
|
if _context_mode != "doc" and _domain not in ("web",):
|
||||||
|
_fs_text = _load_farm_state_snapshot(str(chat_id))
|
||||||
|
if _fs_text:
|
||||||
|
task_parts.append(
|
||||||
|
f"SYSTEM NOTE (не виводь це в відповідь): "
|
||||||
|
f"Farm state snapshot (FarmOS, актуально):\n{_fs_text}"
|
||||||
|
)
|
||||||
|
tlog(logger, "farm_state_snapshot_loaded",
|
||||||
|
chat_id=str(chat_id), found=True,
|
||||||
|
preview=_fs_text[:40])
|
||||||
|
else:
|
||||||
|
tlog(logger, "farm_state_snapshot_loaded",
|
||||||
|
chat_id=str(chat_id), found=False)
|
||||||
|
|
||||||
summary = {
|
# 1. Документ (якщо є вміст)
|
||||||
'ops': ops_out,
|
if _doc_summary_snippet:
|
||||||
'iot': iot_out,
|
task_parts.append(_doc_summary_snippet)
|
||||||
'platform': platform_out,
|
|
||||||
'spreadsheet': sheet_out,
|
# 2. Контекст переписки (chat history з memory-service)
|
||||||
'sustainability': sustainability_out
|
if chat_history:
|
||||||
}
|
# Беремо останні 3000 символів — достатньо для контексту
|
||||||
|
_history_snippet = chat_history[-3000:] if len(chat_history) > 3000 else chat_history
|
||||||
|
task_parts.append(
|
||||||
|
f"=== ІСТОРІЯ ДІАЛОГУ (до 40 повідомлень) ===\n"
|
||||||
|
f"{_history_snippet}\n"
|
||||||
|
f"=== КІНЕЦЬ ІСТОРІЇ ==="
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Контекст профілю якщо є
|
||||||
|
if farm_profile:
|
||||||
|
fields = farm_profile.get("fields", [])
|
||||||
|
if fields:
|
||||||
|
task_parts.append(f"Поля господарства: {', '.join(str(f) for f in fields[:5])}")
|
||||||
|
|
||||||
|
task_parts.append(f"Поточний запит: {text}")
|
||||||
|
|
||||||
|
if _doc_summary_snippet:
|
||||||
|
task_parts.append(
|
||||||
|
"ІНСТРУКЦІЯ: У тебе є вміст документа вище. "
|
||||||
|
"Відповідай ТІЛЬКИ на основі цього документа. "
|
||||||
|
"Якщо є числа — цитуй їх точно. "
|
||||||
|
"Якщо потрібного числа немає — скажи в якому рядку/колонці шукати (1 речення)."
|
||||||
|
)
|
||||||
|
elif chat_history:
|
||||||
|
task_parts.append(
|
||||||
|
"ІНСТРУКЦІЯ: Використовуй ІСТОРІЮ ДІАЛОГУ вище для відповіді. "
|
||||||
|
"Якщо в history є згадка документа або дані — спирайся на них. "
|
||||||
|
"Відповідай коротко і конкретно українською."
|
||||||
|
)
|
||||||
|
elif doc_context and (doc_context.get("file_id") or doc_context.get("file_unique_id")):
|
||||||
|
# Fix E: файл є, але summary порожній і history немає → НЕ казати "немає даних"
|
||||||
|
_f_name = doc_context.get("file_name") or "документ"
|
||||||
|
task_parts.append(
|
||||||
|
f"ІНСТРУКЦІЯ: Файл «{_f_name}» отримано, але вміст ще не витягнутий. "
|
||||||
|
f"НЕ кажи 'немає даних' або 'надішли ще раз'. "
|
||||||
|
f"Відповідай: 'Зараз опрацьовую — дай хвилину' і постав 1 уточнюючи питання "
|
||||||
|
f"про те, що саме потрібно знайти у файлі."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
task_parts.append(
|
||||||
|
"Дай коротку, конкретну відповідь українською. "
|
||||||
|
"Якщо даних недостатньо — скажи що саме потрібно уточнити (1 питання)."
|
||||||
|
)
|
||||||
|
tlog(logger, "deep_context_ready", chat_id=str(chat_id),
|
||||||
|
has_doc=bool(_doc_summary_snippet), has_history=bool(chat_history),
|
||||||
|
history_len=len(chat_history))
|
||||||
|
|
||||||
final_task = Task(
|
final_task = Task(
|
||||||
description=f"Сформуй фінальну коротку відповідь користувачу. Вхідні дані (JSON): {json.dumps(summary, ensure_ascii=False)}",
|
description="\n\n".join(task_parts),
|
||||||
expected_output="Коротка консолідована відповідь для користувача українською.",
|
expected_output="Коротка відповідь для користувача українською мовою.",
|
||||||
agent=stepan
|
agent=stepan
|
||||||
)
|
)
|
||||||
crew = Crew(agents=[stepan], tasks=[final_task], verbose=True)
|
crew = Crew(agents=[stepan], tasks=[final_task], verbose=False)
|
||||||
result = crew.kickoff()
|
result = crew.kickoff()
|
||||||
|
raw_response = str(result) + farmos_ui_hint()
|
||||||
|
styled_response = adapt_response_style(raw_response, user_profile)
|
||||||
|
|
||||||
return str(result) + farmos_ui_hint()
|
# Reflection (Deep mode only, never recursive)
|
||||||
|
reflection = reflect_on_response(text, styled_response, user_profile, farm_profile)
|
||||||
|
if reflection.get("style_shift") and user_profile:
|
||||||
|
user_profile["style"] = reflection["style_shift"]
|
||||||
|
if reflection.get("clarifying_question"):
|
||||||
|
styled_response = styled_response.rstrip() + "\n\n" + reflection["clarifying_question"]
|
||||||
|
if reflection.get("new_facts") and user_id:
|
||||||
|
u = user_profile or {}
|
||||||
|
for k, v in reflection["new_facts"].items():
|
||||||
|
if k == "new_crops":
|
||||||
|
# Handled by update_profile_if_needed / FarmProfile
|
||||||
|
pass
|
||||||
|
elif k in ("name", "role"):
|
||||||
|
u[k] = v
|
||||||
|
if user_id:
|
||||||
|
save_user_profile(str(user_id), u)
|
||||||
|
|
||||||
|
audit_event({**trace, 'agent': 'stepan', 'action': 'reflection',
|
||||||
|
'confidence': reflection.get("confidence"), 'new_facts': list(reflection.get("new_facts", {}).keys())})
|
||||||
|
|
||||||
|
# Soft proactivity (v3: 1 речення max, за умовами)
|
||||||
|
clarifying_q = reflection.get("clarifying_question") if reflection else None
|
||||||
|
styled_response, _ = maybe_add_proactivity(
|
||||||
|
styled_response, user_profile or {}, depth="deep", reflection=reflection
|
||||||
|
)
|
||||||
|
|
||||||
|
update_profile_if_needed(str(user_id), str(chat_id), text, styled_response, intent=intent, depth="deep")
|
||||||
|
|
||||||
|
# ── v3.7: STATE-AWARE DOC ACK ────────────────────────────────────────────
|
||||||
|
# Якщо doc_focus щойно встановлений (focus_active раніше не був) і context_mode==doc,
|
||||||
|
# додаємо короткий префікс (max 60 символів), щоб уникнути "Так, пам'ятаю".
|
||||||
|
_doc_just_activated = (
|
||||||
|
_context_mode == "doc"
|
||||||
|
and _current_doc_id
|
||||||
|
and not _focus_active # _focus_active = стан ДО активації у цьому запиті
|
||||||
|
)
|
||||||
|
if _doc_just_activated:
|
||||||
|
_ack = "По звіту дивлюсь." if _signals.get("has_explicit_doc_token") else "Працюємо зі звітом."
|
||||||
|
# Тільки якщо відповідь не починається з нашого ack
|
||||||
|
if not styled_response.startswith(_ack):
|
||||||
|
styled_response = f"{_ack}\n{styled_response}"
|
||||||
|
tlog(logger, "doc_focus_acknowledged", chat_id=str(chat_id),
|
||||||
|
ack=_ack[:20], explicit=_signals.get("has_explicit_doc_token", False))
|
||||||
|
|
||||||
|
# ── FACT LOCK: витягуємо факти з відповіді і зберігаємо в session ──────
|
||||||
|
_new_facts = extract_doc_facts(styled_response)
|
||||||
|
_merged_facts: dict | None = None
|
||||||
|
if _new_facts:
|
||||||
|
_merged_facts = merge_doc_facts(_session_facts, _new_facts)
|
||||||
|
_conflicts = _merged_facts.get("conflicts", {})
|
||||||
|
tlog(logger, "fact_locked", chat_id=str(chat_id),
|
||||||
|
keys=",".join(k for k in _new_facts if k not in ("conflicts","needs_recheck")),
|
||||||
|
conflicts=bool(_conflicts))
|
||||||
|
# Якщо конфлікт — додаємо 1 речення до відповіді
|
||||||
|
if _conflicts:
|
||||||
|
conflict_key = next(iter(_conflicts))
|
||||||
|
tlog(logger, "fact_conflict", chat_id=str(chat_id), key=conflict_key)
|
||||||
|
styled_response = styled_response.rstrip() + (
|
||||||
|
f"\n\nБачу розбіжність по \"{conflict_key.replace('_uah','').replace('_ha','')}\". "
|
||||||
|
"Підтверди, яке значення правильне."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── SELF-CORRECTION: prefix якщо нова відповідь суперечить попередній ──
|
||||||
|
_self_corr_prefix = build_self_correction(styled_response, _session_facts, session, current_doc_id=_current_doc_id)
|
||||||
|
if _self_corr_prefix:
|
||||||
|
styled_response = _self_corr_prefix + styled_response
|
||||||
|
tlog(logger, "self_corrected", chat_id=str(chat_id))
|
||||||
|
|
||||||
|
# ── pending_action ───────────────────────────────────────────────────────
|
||||||
|
_pending_action: dict | None = None
|
||||||
|
if clarifying_q and intent:
|
||||||
|
_pending_action = {
|
||||||
|
"intent": intent,
|
||||||
|
"what_to_do_next": text,
|
||||||
|
"doc_context": {"doc_id": doc_context.get("doc_id")} if doc_context else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
_new_claims = extract_fact_claims(styled_response)
|
||||||
|
|
||||||
|
# Якщо відповідь в doc-режимі і факти знайдено — вмикаємо/продовжуємо doc_focus
|
||||||
|
_df_update: bool | None = None
|
||||||
|
_df_ts_update: float | None = None
|
||||||
|
if _context_mode == "doc" and _current_doc_id:
|
||||||
|
_df_update = True
|
||||||
|
_df_ts_update = _now_ts
|
||||||
|
if not _focus_active:
|
||||||
|
tlog(logger, "doc_focus_set", chat_id=str(chat_id),
|
||||||
|
reason="deep_doc_answer", doc_id=str(_current_doc_id)[:16])
|
||||||
|
elif _context_mode == "general" and session.get("doc_focus"):
|
||||||
|
# Відповідь в general-режимі — скидаємо фокус (не продовжуємо TTL)
|
||||||
|
_df_update = False
|
||||||
|
_df_ts_update = 0.0
|
||||||
|
|
||||||
|
update_session(
|
||||||
|
str(chat_id), text, depth="deep",
|
||||||
|
agents=["stepan"],
|
||||||
|
last_question=clarifying_q,
|
||||||
|
pending_action=_pending_action,
|
||||||
|
doc_facts=_merged_facts if _merged_facts is not None else (_session_facts or None),
|
||||||
|
fact_claims=_new_claims,
|
||||||
|
active_doc_id=_current_doc_id,
|
||||||
|
doc_focus=_df_update,
|
||||||
|
doc_focus_ts=_df_ts_update,
|
||||||
|
doc_focus_cooldown_until=_df_cooldown_until_update,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── CONTEXT BLEED GUARD (v3.5 / v3.6 / v3.7) ────────────────────────────
|
||||||
|
# Якщо відповідь містить doc-фрази але context_mode == "general" → замінити.
|
||||||
|
# Це блокує "витік" шаблонних фраз навіть коли doc_context не підмішувався.
|
||||||
|
if _context_mode == "general":
|
||||||
|
_BLEED_RE = re.compile(
|
||||||
|
r"у\s+(?:цьому|наданому|даному)\s+документі"
|
||||||
|
r"|в\s+(?:цьому|наданому|даному)\s+документі"
|
||||||
|
r"|у\s+(?:цьому\s+)?звіті|в\s+(?:цьому\s+)?звіті",
|
||||||
|
re.IGNORECASE | re.UNICODE,
|
||||||
|
)
|
||||||
|
_bleed_match = _BLEED_RE.search(styled_response)
|
||||||
|
if _bleed_match:
|
||||||
|
_bleed_phrase = _bleed_match.group(0)
|
||||||
|
tlog(logger, "doc_phrase_suppressed", chat_id=str(chat_id),
|
||||||
|
phrase=_bleed_phrase[:40], mode="general")
|
||||||
|
# v3.6: використовуємо контекстний clarifier замість фіксованої фрази
|
||||||
|
styled_response = build_mode_clarifier(text)
|
||||||
|
|
||||||
|
# ── UX-PHRASE GUARD (v3.7) ────────────────────────────────────────────────
|
||||||
|
# Заміна шаблонних фраз "Так, пам'ятаю" / "Не бачу його перед собою" тощо.
|
||||||
|
_DOC_AWARENESS_RE = re.compile(
|
||||||
|
r"(так,\s*пам['\u2019]ятаю|не\s+бачу\s+його|не\s+бачу\s+перед\s+собою"
|
||||||
|
r"|мені\s+(?:не\s+)?доступний\s+документ)",
|
||||||
|
re.IGNORECASE | re.UNICODE,
|
||||||
|
)
|
||||||
|
if _DOC_AWARENESS_RE.search(styled_response):
|
||||||
|
tlog(logger, "doc_ux_phrase_suppressed", chat_id=str(chat_id))
|
||||||
|
styled_response = re.sub(
|
||||||
|
_DOC_AWARENESS_RE,
|
||||||
|
lambda m: build_mode_clarifier(text),
|
||||||
|
styled_response,
|
||||||
|
count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# v3.7: Заміна "у цьому документі" у general при будь-якому тексті
|
||||||
|
if _context_mode == "general":
|
||||||
|
_DOC_MENTION_RE = re.compile(
|
||||||
|
r"\bзвіт\b",
|
||||||
|
re.IGNORECASE | re.UNICODE,
|
||||||
|
)
|
||||||
|
if _DOC_MENTION_RE.search(styled_response) and "doc_facts" not in str(styled_response[:100]):
|
||||||
|
tlog(logger, "doc_mention_blocked_in_general", chat_id=str(chat_id))
|
||||||
|
|
||||||
|
return styled_response
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ services:
|
|||||||
- SERVICE_ID=router
|
- SERVICE_ID=router
|
||||||
- SERVICE_ROLE=router
|
- SERVICE_ROLE=router
|
||||||
- NATS_URL=nats://dagi-staging-nats:4222
|
- NATS_URL=nats://dagi-staging-nats:4222
|
||||||
|
# ── Persistence backends (can also be set in .env.staging) ────────────
|
||||||
|
- ALERT_BACKEND=postgres
|
||||||
|
- ALERT_DATABASE_URL=${ALERT_DATABASE_URL:-${DATABASE_URL}}
|
||||||
|
- RISK_HISTORY_BACKEND=auto
|
||||||
|
- BACKLOG_BACKEND=auto
|
||||||
|
- INCIDENT_BACKEND=auto
|
||||||
|
- AUDIT_BACKEND=auto
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/router/router_config.yaml:/app/router_config.yaml:ro
|
- ./services/router/router_config.yaml:/app/router_config.yaml:ro
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
/Users/apple/github-projects/microdao-daarion/docs/backups/docs_backup_20260218-091700.tar.gz
|
/Users/apple/github-projects/microdao-daarion/docs/backups/docs_backup_20260226-091701.tar.gz
|
||||||
|
|||||||
@@ -1,62 +1,88 @@
|
|||||||
"""FastAPI app instance for Gateway Bot."""
|
"""
|
||||||
|
FastAPI app instance for Gateway Bot
|
||||||
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from http_api import router as gateway_router
|
from http_api import router as gateway_router
|
||||||
from http_api_doc import router as doc_router
|
from http_api_doc import router as doc_router
|
||||||
from daarion_facade.invoke_api import router as invoke_router
|
|
||||||
from daarion_facade.registry_api import router as registry_router
|
import gateway_boot
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||||
)
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Bot Gateway with DAARWIZZ",
|
title="Bot Gateway with DAARWIZZ",
|
||||||
version="1.1.0",
|
version="1.0.0",
|
||||||
description="Gateway service for Telegram/Discord bots + DAARION public facade"
|
description="Gateway service for Telegram/Discord bots → DAGI Router"
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS for web UI clients (gateway only).
|
# CORS middleware
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=[
|
allow_origins=["*"],
|
||||||
"https://daarion.city",
|
|
||||||
"https://www.daarion.city",
|
|
||||||
"http://localhost:3000",
|
|
||||||
],
|
|
||||||
allow_origin_regex=r"https://.*\.lovable\.app",
|
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["GET", "POST", "OPTIONS"],
|
allow_methods=["*"],
|
||||||
allow_headers=["Authorization", "Content-Type"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Existing gateway routes.
|
# Include gateway routes
|
||||||
app.include_router(gateway_router, prefix="", tags=["gateway"])
|
app.include_router(gateway_router, prefix="", tags=["gateway"])
|
||||||
app.include_router(doc_router, prefix="", tags=["docs"])
|
app.include_router(doc_router, prefix="", tags=["docs"])
|
||||||
|
|
||||||
# Public facade routes for DAARION.city UI.
|
|
||||||
app.include_router(registry_router)
|
@app.on_event("startup")
|
||||||
app.include_router(invoke_router)
|
async def startup_stepan_check():
|
||||||
|
"""Check crews + agromatrix_tools availability. Do not crash gateway if missing."""
|
||||||
|
# Шляхи для inproc: gateway volume (основний) або repo root (dev)
|
||||||
|
gw_dir = str(Path(__file__).parent) # /app/gateway-bot
|
||||||
|
repo_root = os.getenv("AGX_REPO_ROOT", "/opt/microdao-daarion").strip()
|
||||||
|
candidate_paths = [
|
||||||
|
gw_dir, # /app/gateway-bot (crews/ тут)
|
||||||
|
str(Path(gw_dir) / "agromatrix-tools"), # /app/gateway-bot/agromatrix-tools
|
||||||
|
repo_root, # /opt/microdao-daarion
|
||||||
|
str(Path(repo_root) / "packages" / "agromatrix-tools"),
|
||||||
|
str(Path(repo_root) / "packages" / "agromatrix-tools" / "agromatrix_tools"),
|
||||||
|
]
|
||||||
|
for p in candidate_paths:
|
||||||
|
if p and p not in sys.path:
|
||||||
|
sys.path.insert(0, p)
|
||||||
|
try:
|
||||||
|
import crews.agromatrix_crew.run # noqa: F401
|
||||||
|
import agromatrix_tools # noqa: F401
|
||||||
|
gateway_boot.STEPAN_IMPORTS_OK = True
|
||||||
|
logger.info("Stepan inproc: crews + agromatrix_tools OK; STEPAN_IMPORTS_OK=True")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Stepan disabled: crews or agromatrix_tools not available: %s. "
|
||||||
|
"Set AGX_REPO_ROOT, mount crews and packages/agromatrix-tools.",
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
gateway_boot.STEPAN_IMPORTS_OK = False
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
return {
|
return {
|
||||||
"service": "bot-gateway",
|
"service": "bot-gateway",
|
||||||
"version": "1.1.0",
|
"version": "1.0.0",
|
||||||
"agent": "DAARWIZZ",
|
"agent": "DAARWIZZ",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
"POST /telegram/webhook",
|
"POST /telegram/webhook",
|
||||||
"POST /discord/webhook",
|
"POST /discord/webhook",
|
||||||
"GET /v1/registry/agents",
|
"POST /api/doc/parse",
|
||||||
"GET /v1/registry/districts",
|
"POST /api/doc/ingest",
|
||||||
"GET /v1/metrics",
|
"POST /api/doc/ask",
|
||||||
"POST /v1/invoke",
|
"GET /api/doc/context/{session_id}",
|
||||||
"GET /v1/jobs/{job_id}",
|
"GET /health"
|
||||||
"GET /health",
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,27 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from router_client import send_to_router
|
from router_client import send_to_router
|
||||||
from memory_client import memory_client
|
from memory_client import memory_client
|
||||||
|
from vision_guard import (
|
||||||
|
extract_label_from_response as _vg_extract_label,
|
||||||
|
get_vision_lock as _vg_get_lock,
|
||||||
|
set_vision_lock as _vg_set_lock,
|
||||||
|
clear_vision_lock as _vg_clear_lock,
|
||||||
|
set_user_label as _vg_set_user_label,
|
||||||
|
detect_user_override as _vg_detect_override,
|
||||||
|
should_skip_reanalysis as _vg_should_skip,
|
||||||
|
build_low_confidence_clarifier as _vg_build_low_conf,
|
||||||
|
build_locked_reply as _vg_build_locked_reply,
|
||||||
|
)
|
||||||
from services.doc_service import (
|
from services.doc_service import (
|
||||||
parse_document,
|
parse_document,
|
||||||
ingest_document,
|
ingest_document,
|
||||||
ask_about_document,
|
ask_about_document,
|
||||||
get_doc_context
|
get_doc_context,
|
||||||
|
save_chat_doc_context,
|
||||||
|
get_chat_doc_context,
|
||||||
|
fetch_telegram_file_bytes,
|
||||||
|
extract_summary_from_bytes,
|
||||||
|
upsert_chat_doc_context_with_summary,
|
||||||
)
|
)
|
||||||
from behavior_policy import (
|
from behavior_policy import (
|
||||||
should_respond,
|
should_respond,
|
||||||
@@ -44,6 +60,7 @@ from behavior_policy import (
|
|||||||
get_ack_text,
|
get_ack_text,
|
||||||
is_prober_request,
|
is_prober_request,
|
||||||
has_agent_chat_participation,
|
has_agent_chat_participation,
|
||||||
|
has_recent_interaction,
|
||||||
NO_OUTPUT,
|
NO_OUTPUT,
|
||||||
BehaviorDecision,
|
BehaviorDecision,
|
||||||
AGENT_NAME_VARIANTS,
|
AGENT_NAME_VARIANTS,
|
||||||
@@ -51,6 +68,16 @@ from behavior_policy import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_has_recent_interaction(agent_id: str, chat_id: str, user_id: str) -> bool:
|
||||||
|
"""Guard: avoid 500 if has_recent_interaction is missing or raises. Returns False on any error."""
|
||||||
|
try:
|
||||||
|
return bool(has_recent_interaction(agent_id, str(chat_id), str(user_id)))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("has_recent_interaction failed, treating as False: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# Telegram message length limits
|
# Telegram message length limits
|
||||||
TELEGRAM_MAX_MESSAGE_LENGTH = 4096
|
TELEGRAM_MAX_MESSAGE_LENGTH = 4096
|
||||||
TELEGRAM_SAFE_LENGTH = 3500 # Leave room for formatting
|
TELEGRAM_SAFE_LENGTH = 3500 # Leave room for formatting
|
||||||
@@ -654,6 +681,31 @@ def _get_last_pending(chat_id: str) -> list | None:
|
|||||||
return rec.get('items')
|
return rec.get('items')
|
||||||
|
|
||||||
|
|
||||||
|
def _find_doc_in_history(history_text: str) -> dict | None:
|
||||||
|
"""
|
||||||
|
Шукає посилання на документ у тексті chat history.
|
||||||
|
Якщо знаходить рядок '[Документ: ...]' — повертає doc_context stub.
|
||||||
|
Це дозволяє Степану знати про документ навіть без збереженого extracted_summary.
|
||||||
|
"""
|
||||||
|
import re as _re
|
||||||
|
if not history_text:
|
||||||
|
return None
|
||||||
|
# Шукаємо паттерн [Документ: filename.xlsx]
|
||||||
|
matches = _re.findall(r'\[Документ:\s*([^\]]+)\]', history_text)
|
||||||
|
if not matches:
|
||||||
|
# Також шукаємо assistant-повідомлення про документ
|
||||||
|
matches = _re.findall(r'📄[^\n]*\*\*([^*]+)\*\*', history_text)
|
||||||
|
if matches:
|
||||||
|
file_name = matches[-1].strip() # Беремо найновіший
|
||||||
|
return {
|
||||||
|
"doc_id": "",
|
||||||
|
"title": file_name,
|
||||||
|
"extracted_summary": "", # немає вмісту — але є назва
|
||||||
|
"from_history": True,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _set_last_pending(chat_id: str, items: list):
|
def _set_last_pending(chat_id: str, items: list):
|
||||||
LAST_PENDING_STATE[str(chat_id)] = {"ts": time.time(), "items": items}
|
LAST_PENDING_STATE[str(chat_id)] = {"ts": time.time(), "items": items}
|
||||||
|
|
||||||
@@ -992,6 +1044,18 @@ async def druid_telegram_webhook(update: TelegramUpdate):
|
|||||||
|
|
||||||
|
|
||||||
# AGROMATRIX webhook endpoint
|
# AGROMATRIX webhook endpoint
|
||||||
|
# AGX_STEPAN_MODE: inproc = run Crew in-process (default); http = call crewai-service (9010).
|
||||||
|
_STEPAN_MODE = None
|
||||||
|
|
||||||
|
def _get_stepan_mode() -> str:
|
||||||
|
global _STEPAN_MODE
|
||||||
|
if _STEPAN_MODE is None:
|
||||||
|
_STEPAN_MODE = (os.getenv("AGX_STEPAN_MODE", "inproc") or "inproc").strip().lower()
|
||||||
|
if _STEPAN_MODE not in ("inproc", "http"):
|
||||||
|
_STEPAN_MODE = "inproc"
|
||||||
|
logger.info("Stepan mode=%s (AGX_STEPAN_MODE)", _STEPAN_MODE)
|
||||||
|
return _STEPAN_MODE
|
||||||
|
|
||||||
|
|
||||||
async def handle_stepan_message(update: TelegramUpdate, agent_config: AgentConfig) -> Dict[str, Any]:
|
async def handle_stepan_message(update: TelegramUpdate, agent_config: AgentConfig) -> Dict[str, Any]:
|
||||||
update_id = getattr(update, 'update_id', None) or update.update_id
|
update_id = getattr(update, 'update_id', None) or update.update_id
|
||||||
@@ -1004,14 +1068,167 @@ async def handle_stepan_message(update: TelegramUpdate, agent_config: AgentConfi
|
|||||||
|
|
||||||
message = update.message or update.channel_post or {}
|
message = update.message or update.channel_post or {}
|
||||||
text = message.get('text') or message.get('caption') or ''
|
text = message.get('text') or message.get('caption') or ''
|
||||||
if not text:
|
|
||||||
return {"ok": True, "status": "no_text"}
|
|
||||||
|
|
||||||
user = message.get('from', {}) or {}
|
user = message.get('from', {}) or {}
|
||||||
chat = message.get('chat', {}) or {}
|
chat = message.get('chat', {}) or {}
|
||||||
user_id = str(user.get('id', ''))
|
user_id = str(user.get('id', ''))
|
||||||
chat_id = str(chat.get('id', ''))
|
chat_id = str(chat.get('id', ''))
|
||||||
|
|
||||||
|
# ── DOC HANDOFF + EXTRACT-ON-UPLOAD (v3.4 / PROMPT 30) ─────────────────
|
||||||
|
# При отриманні документа (operator path):
|
||||||
|
# 1) зберегти базовий doc_ctx (doc_id, file_name)
|
||||||
|
# 2) для XLSX/XLS/CSV: завантажити байти через Bot API, витягнути summary,
|
||||||
|
# оновити doc_context_chat з extracted_summary → Stepan бачить дані одразу
|
||||||
|
_doc_obj = message.get("document")
|
||||||
|
if _doc_obj and _doc_obj.get("file_id"):
|
||||||
|
_file_id_tg = _doc_obj.get("file_id")
|
||||||
|
_fu_id = _doc_obj.get("file_unique_id") or _file_id_tg
|
||||||
|
_fname = _doc_obj.get("file_name") or ""
|
||||||
|
_bot_token = agent_config.get_telegram_token() or ""
|
||||||
|
_doc_ctx_to_save: dict = {
|
||||||
|
"doc_id": _fu_id,
|
||||||
|
"file_unique_id": _fu_id,
|
||||||
|
"file_id": _file_id_tg,
|
||||||
|
"file_name": _fname,
|
||||||
|
"source": "telegram",
|
||||||
|
# Fix D: явно фіксуємо anchor одразу при upload — run.py може читати без парсингу doc_id
|
||||||
|
"active_doc_id": _fu_id,
|
||||||
|
}
|
||||||
|
# Крок 1: зберегти базовий doc_ctx (await = race-safe)
|
||||||
|
await save_chat_doc_context(chat_id, agent_config.agent_id, _doc_ctx_to_save)
|
||||||
|
logger.info("Doc Handoff: saved base doc_id=%s file=%s", str(_fu_id)[:16], _fname)
|
||||||
|
|
||||||
|
# Крок 2: Extract-on-upload для табличних форматів
|
||||||
|
_fname_lower = _fname.lower()
|
||||||
|
_extractable = _fname_lower.endswith((".xlsx", ".xls", ".csv"))
|
||||||
|
_extract_ok = False
|
||||||
|
if _extractable and _bot_token:
|
||||||
|
# Fix 1: One-shot cache — якщо summary вже є для того самого file_unique_id → skip
|
||||||
|
_existing_ctx = await get_chat_doc_context(chat_id, agent_config.agent_id)
|
||||||
|
_already_have = (
|
||||||
|
_existing_ctx
|
||||||
|
and _existing_ctx.get("extracted_summary")
|
||||||
|
and (_existing_ctx.get("file_unique_id") or _existing_ctx.get("doc_id")) == _fu_id
|
||||||
|
)
|
||||||
|
if _already_have:
|
||||||
|
_extract_ok = True
|
||||||
|
logger.info("doc_extract_skipped reason=already_have_summary chat_id=%s fuid=%s",
|
||||||
|
chat_id, str(_fu_id)[:16])
|
||||||
|
else:
|
||||||
|
logger.info("doc_extract_started chat_id=%s file=%s", chat_id, _fname)
|
||||||
|
try:
|
||||||
|
_file_bytes = await fetch_telegram_file_bytes(_bot_token, _file_id_tg)
|
||||||
|
_extract_summary = extract_summary_from_bytes(_fname, _file_bytes)
|
||||||
|
if _extract_summary:
|
||||||
|
await upsert_chat_doc_context_with_summary(
|
||||||
|
chat_id, agent_config.agent_id, _doc_ctx_to_save, _extract_summary
|
||||||
|
)
|
||||||
|
_extract_ok = True
|
||||||
|
logger.info("doc_extract_done ok=true chat_id=%s chars=%d",
|
||||||
|
chat_id, len(_extract_summary))
|
||||||
|
else:
|
||||||
|
logger.warning("doc_extract_done ok=false reason=empty_summary chat_id=%s", chat_id)
|
||||||
|
except Exception as _ee:
|
||||||
|
logger.warning("doc_extract_done ok=false reason=%s chat_id=%s",
|
||||||
|
str(_ee)[:80], chat_id)
|
||||||
|
|
||||||
|
# Якщо тексту/caption немає — підтверджуємо отримання і виходимо
|
||||||
|
if not text:
|
||||||
|
if _extract_ok:
|
||||||
|
_reply = (
|
||||||
|
f"Прочитав «{_fname}». Можу: (1) витягнути прибуток/витрати, "
|
||||||
|
f"(2) сценарій — добрива×2, (3) зведення грн/га. Що потрібно?"
|
||||||
|
)
|
||||||
|
elif _extractable:
|
||||||
|
_reply = (
|
||||||
|
f"Отримав «{_fname}», але не зміг витягнути дані автоматично. "
|
||||||
|
f"Постав питання — перегляну через пошук по документу."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_reply = (
|
||||||
|
f"Бачу «{_fname}». Що зробити: витягнути прибуток/витрати, "
|
||||||
|
f"сценарій, чи звести по га?"
|
||||||
|
)
|
||||||
|
await send_telegram_message(chat_id, _reply, bot_token=_bot_token)
|
||||||
|
return {"ok": True, "status": "doc_saved"}
|
||||||
|
|
||||||
|
# ── PHOTO BRIDGE (v3.5) ─────────────────────────────────────────────────
|
||||||
|
# Фото в operator path раніше провалювалося через "if not text" і тихо ігнорувалося.
|
||||||
|
# Тепер: делегуємо до process_photo (vision-8b через Router) — той самий шлях,
|
||||||
|
# що використовують всі інші агенти. Агент AgroMatrix вже має спеціальний контекст
|
||||||
|
# (prior_label + agricultural system prompt) у process_photo.
|
||||||
|
_photo_obj = message.get("photo")
|
||||||
|
if _photo_obj and not text:
|
||||||
|
# text може бути caption — вже вище: text = message.get('caption') or ''
|
||||||
|
# якщо caption не порожній — photo+caption піде в text-гілку нижче (Stepan відповідає)
|
||||||
|
# тут тільки "фото без тексту"
|
||||||
|
_username = (user.get('username') or user.get('first_name') or str(user_id))
|
||||||
|
_dao_id = os.getenv("AGX_DAO_ID", "agromatrix-dao")
|
||||||
|
_bot_tok = agent_config.get_telegram_token() or ""
|
||||||
|
logger.info("Photo bridge: routing photo to process_photo chat_id=%s", chat_id)
|
||||||
|
try:
|
||||||
|
_photo_result = await process_photo(
|
||||||
|
agent_config=agent_config,
|
||||||
|
update=update,
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_id=user_id,
|
||||||
|
username=_username,
|
||||||
|
dao_id=_dao_id,
|
||||||
|
photo=_photo_obj,
|
||||||
|
caption_override=None,
|
||||||
|
bypass_media_gate=True, # operator path = завжди відповідати
|
||||||
|
)
|
||||||
|
# v3.5 fix: зберігаємо timestamp фото в session Степана
|
||||||
|
# щоб наступний текстовий запит (words=1) знав що щойно було фото
|
||||||
|
try:
|
||||||
|
from crews.agromatrix_crew.session_context import update_session
|
||||||
|
import time as _ts_mod
|
||||||
|
update_session(
|
||||||
|
chat_id, "[photo]", depth="light", agents=[],
|
||||||
|
last_question=None,
|
||||||
|
last_photo_ts=_ts_mod.time(),
|
||||||
|
)
|
||||||
|
logger.info("Photo bridge: last_photo_ts saved chat_id=%s", chat_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return _photo_result
|
||||||
|
except Exception as _pe:
|
||||||
|
logger.warning("Photo bridge error: %s", _pe)
|
||||||
|
await send_telegram_message(
|
||||||
|
chat_id,
|
||||||
|
"Не вдалося обробити фото. Спробуй ще раз або напиши що на фото.",
|
||||||
|
bot_token=_bot_tok,
|
||||||
|
)
|
||||||
|
return {"ok": True, "status": "photo_error"}
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
return {"ok": True, "status": "no_text"}
|
||||||
|
|
||||||
|
# ── PHOTO+TEXT: якщо є caption → Stepan отримує опис через doc_context ─────
|
||||||
|
# Якщо text (caption) є + фото → стандартний flow Степана + зберігаємо file_id
|
||||||
|
# щоб він міг згадати фото у відповіді.
|
||||||
|
if _photo_obj and text:
|
||||||
|
_photo_largest = _photo_obj[-1] if isinstance(_photo_obj, list) else _photo_obj
|
||||||
|
_photo_file_id = _photo_largest.get("file_id") if isinstance(_photo_largest, dict) else None
|
||||||
|
if _photo_file_id:
|
||||||
|
_set_recent_photo_context(agent_config.agent_id, chat_id, user_id, _photo_file_id)
|
||||||
|
|
||||||
|
# ── VISION CONSISTENCY GUARD: Хук C — User Override ─────────────────────
|
||||||
|
# Whitelist + negation guard: "це соняшник" → user_label;
|
||||||
|
# "це не соняшник" → ігноруємо.
|
||||||
|
if text:
|
||||||
|
try:
|
||||||
|
_vg_override = _vg_detect_override(text)
|
||||||
|
if _vg_override:
|
||||||
|
_vg_set_user_label(agent_config.agent_id, chat_id, _vg_override)
|
||||||
|
logger.info(
|
||||||
|
"vision_user_override_set agent=%s chat_id=%s label=%s",
|
||||||
|
agent_config.agent_id, chat_id, _vg_override,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# ops mode if operator
|
# ops mode if operator
|
||||||
ops_mode = False
|
ops_mode = False
|
||||||
op_ids = [s.strip() for s in os.getenv('AGX_OPERATOR_IDS', '').split(',') if s.strip()]
|
op_ids = [s.strip() for s in os.getenv('AGX_OPERATOR_IDS', '').split(',') if s.strip()]
|
||||||
@@ -1022,21 +1239,152 @@ async def handle_stepan_message(update: TelegramUpdate, agent_config: AgentConfi
|
|||||||
ops_mode = True
|
ops_mode = True
|
||||||
|
|
||||||
trace_id = str(uuid.uuid4())
|
trace_id = str(uuid.uuid4())
|
||||||
|
stepan_mode = _get_stepan_mode()
|
||||||
|
|
||||||
|
if stepan_mode == "http":
|
||||||
|
logger.warning("Stepan http mode not implemented; use AGX_STEPAN_MODE=inproc.")
|
||||||
|
bot_token = agent_config.get_telegram_token()
|
||||||
|
await send_telegram_message(
|
||||||
|
chat_id,
|
||||||
|
"Степан у режимі HTTP зараз недоступний. Встановіть AGX_STEPAN_MODE=inproc.",
|
||||||
|
bot_token=bot_token,
|
||||||
|
)
|
||||||
|
return {"ok": False, "status": "stepan_http_not_implemented"}
|
||||||
|
|
||||||
# call Stepan directly
|
|
||||||
try:
|
try:
|
||||||
sys.path.insert(0, str(Path('/opt/microdao-daarion')))
|
import gateway_boot
|
||||||
|
except ImportError:
|
||||||
|
gateway_boot = type(sys)("gateway_boot")
|
||||||
|
gateway_boot.STEPAN_IMPORTS_OK = False
|
||||||
|
if not getattr(gateway_boot, "STEPAN_IMPORTS_OK", False):
|
||||||
|
logger.warning("Stepan inproc disabled: crews/agromatrix_tools not available at startup")
|
||||||
|
bot_token = agent_config.get_telegram_token()
|
||||||
|
await send_telegram_message(
|
||||||
|
chat_id,
|
||||||
|
"Степан тимчасово недоступний (не встановлено crews або agromatrix-tools).",
|
||||||
|
bot_token=bot_token,
|
||||||
|
)
|
||||||
|
return {"ok": False, "status": "stepan_disabled"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# v3: crews/ is in /app/gateway-bot/crews (volume-mounted copy)
|
||||||
|
# AGX_REPO_ROOT can override for dev/alt deployments
|
||||||
|
repo_root = os.getenv("AGX_REPO_ROOT", "")
|
||||||
|
_gw = "/app/gateway-bot"
|
||||||
|
_at = "/app/gateway-bot/agromatrix-tools"
|
||||||
|
for _p in [_at, _gw, repo_root]:
|
||||||
|
if _p and _p not in sys.path:
|
||||||
|
sys.path.insert(0, _p)
|
||||||
from crews.agromatrix_crew.run import handle_message
|
from crews.agromatrix_crew.run import handle_message
|
||||||
|
|
||||||
|
# Doc Bridge (v3.3): отримати активний doc_context для цього chat.
|
||||||
|
# Пріоритет: chat-scoped (doc_context_chat:) > session-scoped (doc_context:).
|
||||||
|
_stepan_doc_ctx: dict | None = None
|
||||||
|
try:
|
||||||
|
# 1) Спочатку пробуємо chat-scoped (надійніший при зміні session_id)
|
||||||
|
_chat_dc = await get_chat_doc_context(chat_id, agent_config.agent_id)
|
||||||
|
if _chat_dc and (_chat_dc.get("doc_id") or _chat_dc.get("file_unique_id")):
|
||||||
|
_chat_doc_id = _chat_dc.get("doc_id") or _chat_dc.get("file_unique_id")
|
||||||
|
_chat_extracted = _chat_dc.get("extracted_summary") or ""
|
||||||
|
_chat_fname = _chat_dc.get("file_name") or ""
|
||||||
|
# Якщо chat-scoped є але без extracted_summary → шукаємо в session-scoped
|
||||||
|
if not _chat_extracted:
|
||||||
|
try:
|
||||||
|
_dc_sess = await get_doc_context(f"telegram:{chat_id}", agent_id=agent_config.agent_id)
|
||||||
|
if _dc_sess and getattr(_dc_sess, "extracted_summary", None):
|
||||||
|
_chat_extracted = _dc_sess.extracted_summary
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Якщо ще немає — RAG fallback
|
||||||
|
if not _chat_extracted and _chat_doc_id:
|
||||||
|
try:
|
||||||
|
_qa = await ask_about_document(
|
||||||
|
session_id=f"telegram:{chat_id}",
|
||||||
|
question=text,
|
||||||
|
doc_id=_chat_doc_id,
|
||||||
|
dao_id=os.getenv("AGX_DAO_ID", "agromatrix-dao"),
|
||||||
|
user_id=f"tg:{user_id}",
|
||||||
|
agent_id=agent_config.agent_id,
|
||||||
|
)
|
||||||
|
if _qa and getattr(_qa, "answer", None):
|
||||||
|
_chat_extracted = f"[RAG відповідь по документу]: {_qa.answer}"
|
||||||
|
logger.info("Doc Bridge: RAG answer retrieved for chat doc_id=%s", _chat_doc_id)
|
||||||
|
except Exception as _qae:
|
||||||
|
logger.debug("Doc Bridge RAG fallback failed: %s", _qae)
|
||||||
|
_stepan_doc_ctx = {
|
||||||
|
"doc_id": _chat_doc_id,
|
||||||
|
"title": _chat_fname,
|
||||||
|
"extracted_summary": _chat_extracted,
|
||||||
|
"file_unique_id": _chat_dc.get("file_unique_id") or _chat_doc_id,
|
||||||
|
}
|
||||||
|
logger.info("Doc Bridge: chat-scoped doc_id=%s found=true", _chat_doc_id[:16] if _chat_doc_id else "")
|
||||||
|
else:
|
||||||
|
# 2) Fallback: session-scoped (старий ключ)
|
||||||
|
_dc = await get_doc_context(f"telegram:{chat_id}", agent_id=agent_config.agent_id)
|
||||||
|
if _dc and getattr(_dc, "doc_id", None):
|
||||||
|
_extracted = getattr(_dc, "extracted_summary", "") or ""
|
||||||
|
if not _extracted and getattr(_dc, "doc_id", None):
|
||||||
|
try:
|
||||||
|
_qa = await ask_about_document(
|
||||||
|
session_id=f"telegram:{chat_id}",
|
||||||
|
question=text,
|
||||||
|
doc_id=_dc.doc_id,
|
||||||
|
dao_id=os.getenv("AGX_DAO_ID", "agromatrix-dao"),
|
||||||
|
user_id=f"tg:{user_id}",
|
||||||
|
agent_id=agent_config.agent_id,
|
||||||
|
)
|
||||||
|
if _qa and getattr(_qa, "answer", None):
|
||||||
|
_extracted = f"[RAG відповідь по документу]: {_qa.answer}"
|
||||||
|
logger.info("Doc Bridge: session-scoped RAG retrieved for doc_id=%s", _dc.doc_id)
|
||||||
|
except Exception as _qae:
|
||||||
|
logger.debug("Doc Bridge session RAG fallback failed: %s", _qae)
|
||||||
|
_stepan_doc_ctx = {
|
||||||
|
"doc_id": _dc.doc_id,
|
||||||
|
"title": getattr(_dc, "file_name", "") or "",
|
||||||
|
"extracted_summary": _extracted,
|
||||||
|
"file_unique_id": _dc.doc_id,
|
||||||
|
}
|
||||||
|
logger.info("Doc Bridge: session-scoped doc_id=%s found=true", _dc.doc_id)
|
||||||
|
except Exception as _dce:
|
||||||
|
logger.debug("Doc Bridge: could not fetch doc_context: %s", _dce)
|
||||||
|
|
||||||
|
# Chat History Bridge (v3.2): передаємо history з memory-service в Степана.
|
||||||
|
# Степан інакше не має доступу до переписки — він викликається поза Router pipeline.
|
||||||
|
_stepan_chat_history: str = ""
|
||||||
|
try:
|
||||||
|
_ctx = await memory_client.get_context(
|
||||||
|
user_id=f"tg:{user_id}",
|
||||||
|
agent_id=agent_config.agent_id,
|
||||||
|
team_id=os.getenv("AGX_DAO_ID", "agromatrix-dao"),
|
||||||
|
channel_id=chat_id,
|
||||||
|
limit=40,
|
||||||
|
)
|
||||||
|
_stepan_chat_history = _ctx.get("local_context_text", "") or ""
|
||||||
|
# Якщо в history є документ — і _stepan_doc_ctx порожній, шукаємо в history
|
||||||
|
if not _stepan_doc_ctx and _stepan_chat_history:
|
||||||
|
_doc_in_history = _find_doc_in_history(_stepan_chat_history)
|
||||||
|
if _doc_in_history:
|
||||||
|
_stepan_doc_ctx = _doc_in_history
|
||||||
|
logger.info("Doc Bridge: found doc reference in chat history: %s",
|
||||||
|
_doc_in_history.get("title", ""))
|
||||||
|
except Exception as _che:
|
||||||
|
logger.debug("Chat History Bridge failed (non-blocking): %s", _che)
|
||||||
|
|
||||||
started = time.time()
|
started = time.time()
|
||||||
last_pending = _get_last_pending(chat_id)
|
last_pending = _get_last_pending(chat_id)
|
||||||
response_text = await asyncio.wait_for(
|
response_text = await asyncio.wait_for(
|
||||||
asyncio.to_thread(handle_message, text, user_id, chat_id, trace_id, ops_mode, last_pending),
|
asyncio.to_thread(
|
||||||
timeout=25
|
handle_message, text, user_id, chat_id, trace_id, ops_mode, last_pending,
|
||||||
|
None, None, bool(_stepan_doc_ctx), _stepan_doc_ctx,
|
||||||
|
_stepan_chat_history,
|
||||||
|
),
|
||||||
|
timeout=55
|
||||||
)
|
)
|
||||||
duration_ms = int((time.time() - started) * 1000)
|
duration_ms = int((time.time() - started) * 1000)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Stepan handler error: {e}; trace_id={trace_id}")
|
logger.error(f"Stepan handler error: {e}; trace_id={trace_id}")
|
||||||
response_text = f"Помилка обробки. trace_id={trace_id}"
|
# SANITIZE: без trace_id для юзера (trace_id тільки в логах)
|
||||||
|
response_text = "Щось пішло не так. Спробуй ще раз або переформулюй запит."
|
||||||
duration_ms = 0
|
duration_ms = 0
|
||||||
|
|
||||||
# If JSON, try to show summary
|
# If JSON, try to show summary
|
||||||
@@ -1078,35 +1426,19 @@ async def agromatrix_telegram_webhook(update: TelegramUpdate):
|
|||||||
if user_id and user_id in op_ids:
|
if user_id and user_id in op_ids:
|
||||||
is_ops = True
|
is_ops = True
|
||||||
|
|
||||||
# Operator NL or operator slash commands -> handle via Stepan handler.
|
# Operator: any message (not only slash) goes to Stepan when is_ops.
|
||||||
# Important: do NOT treat generic slash commands (/start, /agromatrix) as operator commands,
|
# v3: stepan_enabled checks DEEPSEEK_API_KEY (preferred) OR OPENAI_API_KEY (fallback)
|
||||||
# otherwise regular users will see "Недостатньо прав" or Stepan errors.
|
stepan_enabled = bool(
|
||||||
operator_slash_cmds = {
|
os.getenv("DEEPSEEK_API_KEY", "").strip()
|
||||||
"whoami",
|
or os.getenv("OPENAI_API_KEY", "").strip()
|
||||||
"pending",
|
)
|
||||||
"pending_show",
|
if stepan_enabled and is_ops:
|
||||||
"approve",
|
|
||||||
"reject",
|
|
||||||
"apply_dict",
|
|
||||||
"pending_stats",
|
|
||||||
}
|
|
||||||
slash_cmd = ""
|
|
||||||
if is_slash:
|
|
||||||
try:
|
|
||||||
slash_cmd = (msg_text.strip().split()[0].lstrip("/").strip().lower())
|
|
||||||
except Exception:
|
|
||||||
slash_cmd = ""
|
|
||||||
is_operator_slash = bool(slash_cmd) and slash_cmd in operator_slash_cmds
|
|
||||||
|
|
||||||
# Stepan handler currently depends on ChatOpenAI (OPENAI_API_KEY). If key is not configured,
|
|
||||||
# never route production traffic there (avoid "Помилка обробки..." and webhook 5xx).
|
|
||||||
stepan_enabled = bool(os.getenv("OPENAI_API_KEY", "").strip())
|
|
||||||
if stepan_enabled and (is_ops or is_operator_slash):
|
|
||||||
return await handle_stepan_message(update, AGROMATRIX_CONFIG)
|
return await handle_stepan_message(update, AGROMATRIX_CONFIG)
|
||||||
if (is_ops or is_operator_slash) and not stepan_enabled:
|
if is_ops and not stepan_enabled:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Stepan handler disabled (OPENAI_API_KEY missing); falling back to Router pipeline "
|
"Stepan handler disabled (no DEEPSEEK_API_KEY / OPENAI_API_KEY); "
|
||||||
f"for chat_id={chat_id}, user_id={user_id}, slash_cmd={slash_cmd!r}"
|
"falling back to Router pipeline "
|
||||||
|
f"for chat_id={chat_id}, user_id={user_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# General conversation -> standard Router pipeline (like all other agents)
|
# General conversation -> standard Router pipeline (like all other agents)
|
||||||
@@ -1672,11 +2004,16 @@ async def process_photo(
|
|||||||
# Telegram sends multiple sizes, get the largest one (last in array)
|
# Telegram sends multiple sizes, get the largest one (last in array)
|
||||||
photo_obj = photo[-1] if isinstance(photo, list) else photo
|
photo_obj = photo[-1] if isinstance(photo, list) else photo
|
||||||
file_id = photo_obj.get("file_id") if isinstance(photo_obj, dict) else None
|
file_id = photo_obj.get("file_id") if isinstance(photo_obj, dict) else None
|
||||||
|
# file_unique_id стабільний між розмірами — використовуємо як lock key
|
||||||
|
file_unique_id: str | None = (photo_obj.get("file_unique_id") if isinstance(photo_obj, dict) else None) or None
|
||||||
|
|
||||||
if not file_id:
|
if not file_id:
|
||||||
return {"ok": False, "error": "No file_id in photo"}
|
return {"ok": False, "error": "No file_id in photo"}
|
||||||
|
|
||||||
logger.info(f"{agent_config.name}: Photo from {username} (tg:{user_id}), file_id: {file_id}")
|
logger.info(
|
||||||
|
"%s: Photo from %s (tg:%s), file_id: %s file_unique_id: %s",
|
||||||
|
agent_config.name, username, user_id, file_id, file_unique_id or "n/a",
|
||||||
|
)
|
||||||
_set_recent_photo_context(agent_config.agent_id, chat_id, user_id, file_id)
|
_set_recent_photo_context(agent_config.agent_id, chat_id, user_id, file_id)
|
||||||
if agent_config.agent_id == "agromatrix":
|
if agent_config.agent_id == "agromatrix":
|
||||||
await _set_agromatrix_last_photo_ref(
|
await _set_agromatrix_last_photo_ref(
|
||||||
@@ -1717,7 +2054,28 @@ async def process_photo(
|
|||||||
username=username,
|
username=username,
|
||||||
)
|
)
|
||||||
return {"ok": True, "skipped": True, "reason": "media_no_question"}
|
return {"ok": True, "skipped": True, "reason": "media_no_question"}
|
||||||
|
|
||||||
|
# ── VISION CONSISTENCY GUARD: Rule 1 ─────────────────────────────────────
|
||||||
|
# Те саме фото (file_unique_id або file_id) вже аналізувалось →
|
||||||
|
# повертаємо збережений результат без запиту до Router.
|
||||||
|
# reeval_request → clear_lock → продовжуємо до Router.
|
||||||
|
_vg_caption_text = caption.strip() if caption else ""
|
||||||
|
if agent_config.agent_id == "agromatrix" and _vg_should_skip(
|
||||||
|
agent_config.agent_id, chat_id, file_id, _vg_caption_text,
|
||||||
|
file_unique_id=file_unique_id,
|
||||||
|
):
|
||||||
|
_vg_lock = _vg_get_lock(agent_config.agent_id, chat_id)
|
||||||
|
_vg_reply = _vg_build_locked_reply(_vg_lock, _vg_caption_text)
|
||||||
|
logger.info(
|
||||||
|
"vision_skip_reanalysis agent=%s chat_id=%s photo_key=%s label=%s",
|
||||||
|
agent_config.agent_id, chat_id,
|
||||||
|
file_unique_id or file_id, _vg_lock.get("label", "?"),
|
||||||
|
)
|
||||||
|
telegram_token = agent_config.get_telegram_token() or ""
|
||||||
|
if telegram_token:
|
||||||
|
await send_telegram_message(chat_id, _vg_reply, telegram_token)
|
||||||
|
return {"ok": True, "skipped": True, "reason": "vision_lock_same_photo"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get file path from Telegram
|
# Get file path from Telegram
|
||||||
telegram_token = agent_config.get_telegram_token()
|
telegram_token = agent_config.get_telegram_token()
|
||||||
@@ -1796,6 +2154,32 @@ async def process_photo(
|
|||||||
answer_text = response.get("data", {}).get("text") or response.get("response", "")
|
answer_text = response.get("data", {}).get("text") or response.get("response", "")
|
||||||
|
|
||||||
if answer_text:
|
if answer_text:
|
||||||
|
# ── VISION CONSISTENCY GUARD: Hooks A+B ──────────────────────
|
||||||
|
# A: persist lock (label + confidence) keyed by file_unique_id
|
||||||
|
if agent_config.agent_id == "agromatrix":
|
||||||
|
try:
|
||||||
|
_vg_label, _vg_conf = _vg_extract_label(answer_text)
|
||||||
|
_vg_set_lock(
|
||||||
|
agent_config.agent_id, chat_id, file_id,
|
||||||
|
_vg_label, _vg_conf,
|
||||||
|
file_unique_id=file_unique_id,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"vision_lock_set agent=%s chat_id=%s photo_key=%s label=%s conf=%s",
|
||||||
|
agent_config.agent_id, chat_id,
|
||||||
|
file_unique_id or file_id, _vg_label, _vg_conf,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# B: low-confidence → append clarifier if not already present
|
||||||
|
answer_text, _vg_low_added = _vg_build_low_conf(answer_text)
|
||||||
|
if _vg_low_added:
|
||||||
|
logger.info(
|
||||||
|
"vision_low_conf_clarifier_added agent=%s chat_id=%s",
|
||||||
|
agent_config.agent_id, chat_id,
|
||||||
|
)
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Photo processed - send LLM response directly
|
# Photo processed - send LLM response directly
|
||||||
await send_telegram_message(
|
await send_telegram_message(
|
||||||
chat_id,
|
chat_id,
|
||||||
@@ -1941,7 +2325,8 @@ async def process_document(
|
|||||||
dao_id=dao_id,
|
dao_id=dao_id,
|
||||||
user_id=f"tg:{user_id}",
|
user_id=f"tg:{user_id}",
|
||||||
output_mode="qa_pairs",
|
output_mode="qa_pairs",
|
||||||
metadata={"username": username, "chat_id": chat_id}
|
metadata={"username": username, "chat_id": chat_id},
|
||||||
|
agent_id=agent_config.agent_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not result.success:
|
if not result.success:
|
||||||
@@ -1953,7 +2338,42 @@ async def process_document(
|
|||||||
if not doc_text and result.chunks_meta:
|
if not doc_text and result.chunks_meta:
|
||||||
chunks = result.chunks_meta.get("chunks", [])
|
chunks = result.chunks_meta.get("chunks", [])
|
||||||
doc_text = "\n".join(chunks[:5]) if chunks else ""
|
doc_text = "\n".join(chunks[:5]) if chunks else ""
|
||||||
|
|
||||||
|
# v3.2 Doc Bridge: зберігаємо parsed text щоб Stepan міг відповідати на питання
|
||||||
|
if doc_text and result.doc_id:
|
||||||
|
try:
|
||||||
|
from services.doc_service import save_doc_context as _save_doc_ctx
|
||||||
|
await _save_doc_ctx(
|
||||||
|
session_id=session_id,
|
||||||
|
doc_id=result.doc_id,
|
||||||
|
doc_url=file_url,
|
||||||
|
file_name=file_name,
|
||||||
|
dao_id=dao_id,
|
||||||
|
user_id=f"tg:{user_id}",
|
||||||
|
agent_id=agent_config.agent_id,
|
||||||
|
extracted_summary=doc_text[:4000],
|
||||||
|
)
|
||||||
|
logger.info(f"Doc Bridge: saved extracted_summary ({len(doc_text)} chars) for doc_id={result.doc_id}")
|
||||||
|
except Exception as _dbe:
|
||||||
|
logger.warning(f"Doc Bridge save_doc_context failed (non-blocking): {_dbe}")
|
||||||
|
# v3.3 Doc Handoff: зберігаємо chat-scoped ключ (пріоритет для Stepan)
|
||||||
|
try:
|
||||||
|
from services.doc_service import _sanitize_summary as _ss
|
||||||
|
_file_unique = document.get("file_unique_id") or result.doc_id
|
||||||
|
await save_chat_doc_context(
|
||||||
|
chat_id=chat_id,
|
||||||
|
agent_id=agent_config.agent_id,
|
||||||
|
doc_ctx={
|
||||||
|
"doc_id": result.doc_id,
|
||||||
|
"file_unique_id": _file_unique,
|
||||||
|
"file_name": file_name,
|
||||||
|
"extracted_summary": _ss(doc_text)[:4000],
|
||||||
|
"source": "telegram",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as _cdbe:
|
||||||
|
logger.warning("Doc Handoff: save_chat_doc_context failed: %s", _cdbe)
|
||||||
|
|
||||||
# Ask LLM to summarize the document (human-friendly)
|
# Ask LLM to summarize the document (human-friendly)
|
||||||
if doc_text:
|
if doc_text:
|
||||||
zip_hint = None
|
zip_hint = None
|
||||||
@@ -3097,7 +3517,7 @@ async def handle_telegram_webhook(
|
|||||||
|
|
||||||
# Check if there's a document context for follow-up questions
|
# Check if there's a document context for follow-up questions
|
||||||
session_id = f"telegram:{chat_id}"
|
session_id = f"telegram:{chat_id}"
|
||||||
doc_context = await get_doc_context(session_id)
|
doc_context = await get_doc_context(session_id, agent_id=agent_config.agent_id)
|
||||||
|
|
||||||
# If there's a doc_id and the message looks like a question about the document
|
# If there's a doc_id and the message looks like a question about the document
|
||||||
if doc_context and doc_context.doc_id:
|
if doc_context and doc_context.doc_id:
|
||||||
@@ -3788,7 +4208,8 @@ async def _old_telegram_webhook(update: TelegramUpdate):
|
|||||||
dao_id=dao_id,
|
dao_id=dao_id,
|
||||||
user_id=f"tg:{user_id}",
|
user_id=f"tg:{user_id}",
|
||||||
output_mode="qa_pairs",
|
output_mode="qa_pairs",
|
||||||
metadata={"username": username, "chat_id": chat_id}
|
metadata={"username": username, "chat_id": chat_id},
|
||||||
|
agent_id=agent_config.agent_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not result.success:
|
if not result.success:
|
||||||
@@ -3991,7 +4412,7 @@ async def _old_telegram_webhook(update: TelegramUpdate):
|
|||||||
|
|
||||||
# Check if there's a document context for follow-up questions
|
# Check if there's a document context for follow-up questions
|
||||||
session_id = f"telegram:{chat_id}"
|
session_id = f"telegram:{chat_id}"
|
||||||
doc_context = await get_doc_context(session_id)
|
doc_context = await get_doc_context(session_id, agent_id=agent_config.agent_id)
|
||||||
|
|
||||||
# If there's a doc_id and the message looks like a question about the document
|
# If there's a doc_id and the message looks like a question about the document
|
||||||
if doc_context and doc_context.doc_id:
|
if doc_context and doc_context.doc_id:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,97 @@
|
|||||||
|
"""
|
||||||
|
common.py — shared auth helpers для farmOS tools.
|
||||||
|
|
||||||
|
farmOS 4.x підтримує тільки OAuth2 Bearer (не Basic Auth).
|
||||||
|
При наявності FARMOS_TOKEN — використовуємо його як статичний Bearer.
|
||||||
|
При наявності FARMOS_USER + FARMOS_PASS — отримуємо JWT через OAuth2
|
||||||
|
password grant і кешуємо його в пам'яті до закінчення TTL.
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
import json
|
import time
|
||||||
|
import threading
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
# ── In-memory OAuth2 token cache (process-level, thread-safe) ────────────────
|
||||||
|
_token_cache: dict = {}
|
||||||
|
_token_lock = threading.Lock()
|
||||||
|
|
||||||
def _auth_headers():
|
_OAUTH_TOKEN_PATH = "/oauth/token"
|
||||||
token = os.getenv("FARMOS_TOKEN")
|
# Оновлюємо токен за 60 секунд до закінчення, щоб не отримати 401
|
||||||
|
_TOKEN_REFRESH_BUFFER_S = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _farmos_base_url() -> str:
|
||||||
|
return os.getenv("FARMOS_BASE_URL", "").strip().rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_oauth_token(base_url: str, user: str, pwd: str, client_id: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Робить POST /oauth/token з password grant.
|
||||||
|
Fail-closed: будь-яка помилка → повертає None (без raise).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
base_url + _OAUTH_TOKEN_PATH,
|
||||||
|
data={
|
||||||
|
"grant_type": "password",
|
||||||
|
"username": user,
|
||||||
|
"password": pwd,
|
||||||
|
"client_id": client_id,
|
||||||
|
"scope": "",
|
||||||
|
},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
access_token = data.get("access_token", "")
|
||||||
|
expires_in = float(data.get("expires_in", 3600))
|
||||||
|
if access_token:
|
||||||
|
with _token_lock:
|
||||||
|
_token_cache["access_token"] = access_token
|
||||||
|
_token_cache["expires_at"] = time.monotonic() + expires_in - _TOKEN_REFRESH_BUFFER_S
|
||||||
|
return access_token
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cached_oauth_token(base_url: str, user: str, pwd: str, client_id: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Повертає кешований токен або отримує новий якщо протух / відсутній.
|
||||||
|
"""
|
||||||
|
with _token_lock:
|
||||||
|
cached = _token_cache.get("access_token")
|
||||||
|
expires_at = _token_cache.get("expires_at", 0.0)
|
||||||
|
if cached and time.monotonic() < expires_at:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# Поза lock — робимо мережевий запит
|
||||||
|
return _fetch_oauth_token(base_url, user, pwd, client_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers() -> dict:
|
||||||
|
"""
|
||||||
|
Повертає заголовки авторизації для farmOS JSON:API запитів.
|
||||||
|
|
||||||
|
Пріоритет:
|
||||||
|
1. FARMOS_TOKEN (статичний Bearer — для dev/тестів або PAT)
|
||||||
|
2. FARMOS_USER + FARMOS_PASS → OAuth2 password grant → Bearer JWT
|
||||||
|
3. Порожній dict (fail-closed: запит піде без auth, farmOS поверне 403/401)
|
||||||
|
"""
|
||||||
|
# 1. Статичний токен
|
||||||
|
token = os.getenv("FARMOS_TOKEN", "").strip()
|
||||||
if token:
|
if token:
|
||||||
return {"Authorization": f"Bearer {token}"}
|
return {"Authorization": f"Bearer {token}"}
|
||||||
user = os.getenv("FARMOS_USER")
|
|
||||||
pwd = os.getenv("FARMOS_PASSWORD")
|
# 2. OAuth2 password grant
|
||||||
if user and pwd:
|
user = os.getenv("FARMOS_USER", "").strip()
|
||||||
import base64
|
pwd = os.getenv("FARMOS_PASS", os.getenv("FARMOS_PASSWORD", "")).strip()
|
||||||
auth = base64.b64encode(f"{user}:{pwd}".encode()).decode()
|
client_id = os.getenv("FARMOS_CLIENT_ID", "farm").strip()
|
||||||
return {"Authorization": f"Basic {auth}"}
|
base_url = _farmos_base_url()
|
||||||
|
|
||||||
|
if user and pwd and base_url:
|
||||||
|
jwt = _get_cached_oauth_token(base_url, user, pwd, client_id)
|
||||||
|
if jwt:
|
||||||
|
return {"Authorization": f"Bearer {jwt}"}
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -1,11 +1,542 @@
|
|||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
|
from urllib.parse import quote as _urlquote
|
||||||
from .audit import audit_tool_call
|
from .audit import audit_tool_call
|
||||||
import requests
|
import requests
|
||||||
from .common import _auth_headers
|
from .common import _auth_headers
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Динамічно читаємо при кожному виклику (не кешуємо на рівні модуля),
|
||||||
|
# щоб env-зміни без рестарту підхоплювались у тестах.
|
||||||
|
def _farmos_base_url() -> str:
|
||||||
|
return os.getenv("FARMOS_BASE_URL", "").strip()
|
||||||
|
|
||||||
FARMOS_BASE_URL = os.getenv("FARMOS_BASE_URL", "http://localhost:8080")
|
FARMOS_BASE_URL = os.getenv("FARMOS_BASE_URL", "http://localhost:8080")
|
||||||
|
|
||||||
|
# ── Shared limits ─────────────────────────────────────────────────────────────
|
||||||
|
_MAX_LIMIT = 20
|
||||||
|
_MIN_LIMIT = 1
|
||||||
|
_OUTPUT_MAX_LINES = 12
|
||||||
|
|
||||||
|
# ── whitelist для farmos_read_logs ───────────────────────────────────────────
|
||||||
|
_VALID_LOG_TYPES = {"activity", "observation", "harvest", "input", "seeding"}
|
||||||
|
|
||||||
|
# ── whitelist для farmos_search_assets ───────────────────────────────────────
|
||||||
|
_VALID_ASSET_TYPES = {
|
||||||
|
"asset_land", "asset_plant", "asset_equipment",
|
||||||
|
"asset_structure", "asset_animal",
|
||||||
|
}
|
||||||
|
|
||||||
|
# farmOS 4.x JSON:API URL: /api/asset/<type> і /api/log/<type>
|
||||||
|
# Наші whitelist ключі типу "asset_land" → "/api/asset/land"
|
||||||
|
def _asset_type_to_path(asset_type: str) -> str:
|
||||||
|
"""Конвертує 'asset_land' → 'asset/land' для URL."""
|
||||||
|
# asset_land → asset/land, asset_plant → asset/plant, etc.
|
||||||
|
if asset_type.startswith("asset_"):
|
||||||
|
return "asset/" + asset_type[len("asset_"):]
|
||||||
|
return asset_type # fallback (не має траплятися)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Нормалізація типу помилки → human-readable мітка ───────────────────────
|
||||||
|
def _classify_exception(exc: Exception) -> str:
|
||||||
|
"""Повертає одну з мітель: timeout / dns / ssl / connect / other."""
|
||||||
|
name = type(exc).__name__
|
||||||
|
msg = str(exc).lower()
|
||||||
|
if "timeout" in name.lower() or "timeout" in msg:
|
||||||
|
return "timeout"
|
||||||
|
if "name or service not known" in msg or "nodename nor servname" in msg or "dns" in msg:
|
||||||
|
return "dns"
|
||||||
|
if "ssl" in msg or "ssl" in name.lower() or "certificate" in msg:
|
||||||
|
return "ssl"
|
||||||
|
if "connect" in name.lower() or "connection" in msg:
|
||||||
|
return "connect"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
|
# ── v4.3: farmos_ping — fail-closed CrewAI tool ──────────────────────────────
|
||||||
|
# Підключається до operations_agent. Ніколи не кидає виняток.
|
||||||
|
# Не виводить у відповідь URL або токени.
|
||||||
|
def _make_lc_tool(name: str, description: str, func):
|
||||||
|
"""
|
||||||
|
Обгортає plain function у langchain_core.tools.Tool для сумісності
|
||||||
|
зі старими версіями crewai (які не мають crewai.tools.tool декоратора).
|
||||||
|
Fallback: повертає ту саму plain function без обгортки.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from langchain_core.tools import Tool as _LCTool
|
||||||
|
return _LCTool(name=name, description=description, func=func)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from langchain.tools import Tool as _LCTool2 # type: ignore[import]
|
||||||
|
return _LCTool2(name=name, description=description, func=func)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Fallback: plain function (для середовищ без crewai/langchain взагалі)
|
||||||
|
return func
|
||||||
|
|
||||||
|
|
||||||
|
def _farmos_ping_raw(query: str = "") -> str: # noqa: ARG001
|
||||||
|
return _farmos_ping_impl()
|
||||||
|
|
||||||
|
|
||||||
|
farmos_ping = _make_lc_tool(
|
||||||
|
name="farmos_ping",
|
||||||
|
description=(
|
||||||
|
"Перевіряє доступність і авторизацію farmOS. "
|
||||||
|
"Повертає стан: доступний / недоступний / не налаштований. "
|
||||||
|
"Використовуй для швидкої діагностики перед іншими farmOS-операціями."
|
||||||
|
),
|
||||||
|
func=_farmos_ping_raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _farmos_ping_impl() -> str:
|
||||||
|
"""
|
||||||
|
Логіка farmos_ping, незалежна від декоратора.
|
||||||
|
Fail-closed: будь-яка помилка → зрозумілий рядок.
|
||||||
|
"""
|
||||||
|
_t = time.time()
|
||||||
|
base_url = _farmos_base_url()
|
||||||
|
|
||||||
|
if not base_url:
|
||||||
|
_tlog_farmos("farmos_ping", ok=False, reason="no_base_url", ms=int((time.time() - _t) * 1000))
|
||||||
|
return "FarmOS не налаштований: FARMOS_BASE_URL відсутній."
|
||||||
|
|
||||||
|
if not _has_auth():
|
||||||
|
_tlog_farmos("farmos_ping", ok=False, reason="no_auth", ms=int((time.time() - _t) * 1000))
|
||||||
|
return "FarmOS URL заданий, але немає токена або логіну/паролю."
|
||||||
|
|
||||||
|
# HTTP healthcheck
|
||||||
|
try:
|
||||||
|
ping_url = base_url.rstrip("/") + "/api"
|
||||||
|
headers = _auth_headers()
|
||||||
|
resp = requests.get(ping_url, headers=headers, timeout=3)
|
||||||
|
ms = int((time.time() - _t) * 1000)
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
_tlog_farmos("farmos_ping", ok=True, reason="ok", http=True, status=200, ms=ms)
|
||||||
|
return "FarmOS доступний."
|
||||||
|
if resp.status_code in (401, 403):
|
||||||
|
_tlog_farmos("farmos_ping", ok=False, reason="auth_error", http=True, status=resp.status_code, ms=ms)
|
||||||
|
return f"FarmOS недоступний: помилка авторизації ({resp.status_code})."
|
||||||
|
_tlog_farmos("farmos_ping", ok=False, reason=f"http_{resp.status_code}", http=True, status=resp.status_code, ms=ms)
|
||||||
|
return f"FarmOS недоступний: HTTP {resp.status_code}."
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
ms = int((time.time() - _t) * 1000)
|
||||||
|
_tlog_farmos("farmos_ping", ok=False, reason="timeout", err="timeout", ms=ms)
|
||||||
|
return "FarmOS недоступний: timeout (3s)."
|
||||||
|
except Exception as exc:
|
||||||
|
ms = int((time.time() - _t) * 1000)
|
||||||
|
err_kind = _classify_exception(exc)
|
||||||
|
_tlog_farmos("farmos_ping", ok=False, reason=err_kind, err=err_kind, ms=ms)
|
||||||
|
if err_kind == "dns":
|
||||||
|
return "FarmOS недоступний: DNS/host не знайдено."
|
||||||
|
if err_kind == "ssl":
|
||||||
|
return "FarmOS недоступний: TLS/SSL помилка."
|
||||||
|
if err_kind == "connect":
|
||||||
|
return "FarmOS недоступний: з'єднання відхилено."
|
||||||
|
return f"FarmOS недоступний: {type(exc).__name__}."
|
||||||
|
|
||||||
|
|
||||||
|
# ── v4.4: farmos_read_logs — read-only logs tool ─────────────────────────────
|
||||||
|
def _farmos_read_logs_raw(log_type: str = "activity", limit: int = 10) -> str:
|
||||||
|
return _farmos_read_logs_impl(log_type=log_type, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
farmos_read_logs = _make_lc_tool(
|
||||||
|
name="farmos_read_logs",
|
||||||
|
description=(
|
||||||
|
"Читає останні записи farmOS (логи операцій). "
|
||||||
|
"log_type: activity | observation | harvest | input | seeding. "
|
||||||
|
"limit: 1..20. Тільки читання. Fail-closed."
|
||||||
|
),
|
||||||
|
func=_farmos_read_logs_raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _farmos_read_logs_impl(log_type: str = "activity", limit: int = 10) -> str:
|
||||||
|
"""
|
||||||
|
Отримує останні N записів farmOS для заданого типу логу.
|
||||||
|
Fail-closed: будь-яка помилка → зрозумілий рядок.
|
||||||
|
"""
|
||||||
|
_t = time.time()
|
||||||
|
|
||||||
|
# Валідація вхідних параметрів
|
||||||
|
if log_type not in _VALID_LOG_TYPES:
|
||||||
|
valid = ", ".join(sorted(_VALID_LOG_TYPES))
|
||||||
|
return f"FarmOS: log_type має бути одним з: {valid}."
|
||||||
|
limit = max(_MIN_LIMIT, min(_MAX_LIMIT, int(limit)))
|
||||||
|
|
||||||
|
base_url = _farmos_base_url()
|
||||||
|
if not base_url:
|
||||||
|
_tlog_farmos("farmos_read_logs", ok=False, reason="no_base_url",
|
||||||
|
log_type=log_type, limit=limit, ms=0)
|
||||||
|
return "FarmOS не налаштований: FARMOS_BASE_URL відсутній."
|
||||||
|
|
||||||
|
if not _has_auth():
|
||||||
|
_tlog_farmos("farmos_read_logs", ok=False, reason="no_auth",
|
||||||
|
log_type=log_type, limit=limit, ms=0)
|
||||||
|
return "FarmOS URL заданий, але немає токена або логіну/паролю."
|
||||||
|
|
||||||
|
headers = _auth_headers()
|
||||||
|
|
||||||
|
# Пробуємо з sort=-changed, fallback без sort (деякі версії не підтримують)
|
||||||
|
param_sets = [
|
||||||
|
{"page[limit]": limit, "sort": "-changed"},
|
||||||
|
{"page[limit]": limit},
|
||||||
|
]
|
||||||
|
url = f"{base_url.rstrip('/')}/api/log/{log_type}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = _try_requests(url, headers, param_sets, timeout=5)
|
||||||
|
ms = int((time.time() - _t) * 1000)
|
||||||
|
|
||||||
|
if resp is None or resp.status_code == 404:
|
||||||
|
_tlog_farmos("farmos_read_logs", ok=False, reason="not_found", http=True,
|
||||||
|
status=404, log_type=log_type, limit=limit, ms=ms)
|
||||||
|
return "FarmOS: endpoint для логів не знайдено (404)."
|
||||||
|
if resp.status_code in (401, 403):
|
||||||
|
_tlog_farmos("farmos_read_logs", ok=False, reason="auth_error", http=True,
|
||||||
|
status=resp.status_code, log_type=log_type, limit=limit, ms=ms)
|
||||||
|
return f"FarmOS недоступний: помилка авторизації ({resp.status_code})."
|
||||||
|
if resp.status_code != 200:
|
||||||
|
_tlog_farmos("farmos_read_logs", ok=False, reason=f"http_{resp.status_code}",
|
||||||
|
http=True, status=resp.status_code, log_type=log_type, limit=limit, ms=ms)
|
||||||
|
return f"FarmOS: помилка запиту ({resp.status_code})."
|
||||||
|
|
||||||
|
items, parse_err = _parse_jsonapi_list(resp)
|
||||||
|
if parse_err:
|
||||||
|
_tlog_farmos("farmos_read_logs", ok=False, reason="parse_error", http=True,
|
||||||
|
status=200, log_type=log_type, limit=limit, ms=ms)
|
||||||
|
return parse_err
|
||||||
|
if not items:
|
||||||
|
_tlog_farmos("farmos_read_logs", ok=True, reason="empty", http=True,
|
||||||
|
status=200, log_type=log_type, limit=limit, ms=ms)
|
||||||
|
return "FarmOS: записів не знайдено."
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for item in items:
|
||||||
|
try:
|
||||||
|
attrs = item.get("attributes", {}) if isinstance(item, dict) else {}
|
||||||
|
# Patch 2: розширений маппінг полів
|
||||||
|
name = _extract_name(attrs, fallback=log_type)
|
||||||
|
date_str = _extract_date(attrs)
|
||||||
|
# Patch 2: notes може бути dict {value:...} або description
|
||||||
|
notes_raw = (
|
||||||
|
attrs.get("notes")
|
||||||
|
or attrs.get("notes_value")
|
||||||
|
or attrs.get("description")
|
||||||
|
or {}
|
||||||
|
)
|
||||||
|
if isinstance(notes_raw, dict):
|
||||||
|
notes_raw = notes_raw.get("value") or ""
|
||||||
|
# Patch 1: normalize whitespace + word-boundary truncation
|
||||||
|
notes = _safe_notes(str(notes_raw))
|
||||||
|
lines.append(f"- {name} | {date_str} | {notes or '—'}")
|
||||||
|
except Exception:
|
||||||
|
lines.append("- (помилка читання запису)")
|
||||||
|
|
||||||
|
if len(lines) > _OUTPUT_MAX_LINES:
|
||||||
|
lines = lines[:_OUTPUT_MAX_LINES]
|
||||||
|
|
||||||
|
_tlog_farmos("farmos_read_logs", ok=True, reason="ok", http=True,
|
||||||
|
status=200, log_type=log_type, limit=limit, ms=ms)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
ms = int((time.time() - _t) * 1000)
|
||||||
|
_tlog_farmos("farmos_read_logs", ok=False, reason="timeout", err="timeout",
|
||||||
|
log_type=log_type, limit=limit, ms=ms)
|
||||||
|
return "FarmOS: timeout при отриманні логів (5s)."
|
||||||
|
except Exception as exc:
|
||||||
|
ms = int((time.time() - _t) * 1000)
|
||||||
|
err_kind = _classify_exception(exc)
|
||||||
|
_tlog_farmos("farmos_read_logs", ok=False, reason=err_kind, err=err_kind,
|
||||||
|
log_type=log_type, limit=limit, ms=ms)
|
||||||
|
return "FarmOS: не вдалося отримати логи (внутрішня помилка)."
|
||||||
|
|
||||||
|
|
||||||
|
# ── v4.5: farmos_search_assets — read-only asset search ──────────────────────
|
||||||
|
def _farmos_search_assets_raw(
|
||||||
|
asset_type: str = "asset_land",
|
||||||
|
name_contains: str = "",
|
||||||
|
limit: int = 10,
|
||||||
|
) -> str:
|
||||||
|
return _farmos_search_assets_impl(
|
||||||
|
asset_type=asset_type,
|
||||||
|
name_contains=name_contains,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
farmos_search_assets = _make_lc_tool(
|
||||||
|
name="farmos_search_assets",
|
||||||
|
description=(
|
||||||
|
"Шукає активи farmOS за типом і необов'язковим підрядком назви. "
|
||||||
|
"asset_type: asset_land | asset_plant | asset_equipment | asset_structure | asset_animal. "
|
||||||
|
"Тільки читання. Fail-closed."
|
||||||
|
),
|
||||||
|
func=_farmos_search_assets_raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _farmos_search_assets_impl(
|
||||||
|
asset_type: str = "asset_land",
|
||||||
|
name_contains: str = "",
|
||||||
|
limit: int = 10,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Пошук активів farmOS через JSON:API. Fail-closed.
|
||||||
|
"""
|
||||||
|
_t = time.time()
|
||||||
|
|
||||||
|
# Валідація
|
||||||
|
if asset_type not in _VALID_ASSET_TYPES:
|
||||||
|
valid = ", ".join(sorted(_VALID_ASSET_TYPES))
|
||||||
|
return f"FarmOS: asset_type має бути одним з: {valid}."
|
||||||
|
limit = max(_MIN_LIMIT, min(_MAX_LIMIT, int(limit)))
|
||||||
|
name_contains = str(name_contains).strip()[:50] # детерміновано обрізаємо
|
||||||
|
|
||||||
|
base_url = _farmos_base_url()
|
||||||
|
if not base_url:
|
||||||
|
_tlog_farmos("farmos_search_assets", ok=False, reason="no_base_url",
|
||||||
|
asset_type=asset_type, limit=limit, ms=0)
|
||||||
|
return "FarmOS не налаштований: FARMOS_BASE_URL відсутній."
|
||||||
|
|
||||||
|
if not _has_auth():
|
||||||
|
_tlog_farmos("farmos_search_assets", ok=False, reason="no_auth",
|
||||||
|
asset_type=asset_type, limit=limit, ms=0)
|
||||||
|
return "FarmOS URL заданий, але немає токена або логіну/паролю."
|
||||||
|
|
||||||
|
headers = _auth_headers()
|
||||||
|
url = f"{base_url.rstrip('/')}/api/{_asset_type_to_path(asset_type)}"
|
||||||
|
|
||||||
|
# Параметри: спробуємо з server-side filter, потім без (client-side fallback)
|
||||||
|
# farmOS 4.x сортує по "name", не "label" (label → 400)
|
||||||
|
base_params = {"page[limit]": limit, "sort": "name"}
|
||||||
|
no_sort_params = {"page[limit]": limit}
|
||||||
|
if name_contains:
|
||||||
|
param_sets = [
|
||||||
|
{**base_params, "filter[name][value]": name_contains},
|
||||||
|
base_params,
|
||||||
|
no_sort_params,
|
||||||
|
]
|
||||||
|
client_filter = True
|
||||||
|
else:
|
||||||
|
param_sets = [base_params, no_sort_params]
|
||||||
|
client_filter = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = _try_requests(url, headers, param_sets, timeout=5)
|
||||||
|
ms = int((time.time() - _t) * 1000)
|
||||||
|
|
||||||
|
if resp is None or resp.status_code == 404:
|
||||||
|
_tlog_farmos("farmos_search_assets", ok=False, reason="not_found", http=True,
|
||||||
|
status=404, asset_type=asset_type, limit=limit, ms=ms)
|
||||||
|
return "FarmOS: endpoint для asset не знайдено (404)."
|
||||||
|
if resp.status_code in (401, 403):
|
||||||
|
_tlog_farmos("farmos_search_assets", ok=False, reason="auth_error", http=True,
|
||||||
|
status=resp.status_code, asset_type=asset_type, limit=limit, ms=ms)
|
||||||
|
return f"FarmOS: помилка авторизації ({resp.status_code})."
|
||||||
|
if resp.status_code != 200:
|
||||||
|
_tlog_farmos("farmos_search_assets", ok=False, reason=f"http_{resp.status_code}",
|
||||||
|
http=True, status=resp.status_code, asset_type=asset_type, limit=limit, ms=ms)
|
||||||
|
return f"FarmOS: помилка запиту ({resp.status_code})."
|
||||||
|
|
||||||
|
items, parse_err = _parse_jsonapi_list(resp)
|
||||||
|
if parse_err:
|
||||||
|
_tlog_farmos("farmos_search_assets", ok=False, reason="parse_error", http=True,
|
||||||
|
status=200, asset_type=asset_type, limit=limit, ms=ms)
|
||||||
|
return parse_err
|
||||||
|
|
||||||
|
# Client-side substring filter (plain, no regex)
|
||||||
|
if client_filter and name_contains and items:
|
||||||
|
needle = name_contains.lower()
|
||||||
|
items = [
|
||||||
|
it for it in items
|
||||||
|
if needle in _extract_label(it).lower()
|
||||||
|
]
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
_tlog_farmos("farmos_search_assets", ok=True, reason="empty", http=True,
|
||||||
|
status=200, asset_type=asset_type, limit=limit,
|
||||||
|
filtered=bool(name_contains), count=0, ms=ms)
|
||||||
|
return "FarmOS: нічого не знайдено."
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for item in items:
|
||||||
|
try:
|
||||||
|
label = _extract_label(item)
|
||||||
|
item_type = str(item.get("type", asset_type))
|
||||||
|
item_id = str(item.get("id", ""))[:8] # тільки перші 8 символів UUID
|
||||||
|
line = f"- {label} | {item_type} | id={item_id}"
|
||||||
|
# Patch 1: normalize і обріз рядка до 120 символів
|
||||||
|
line = re.sub(r"\s+", " ", line).strip()[:120]
|
||||||
|
lines.append(line)
|
||||||
|
except Exception:
|
||||||
|
lines.append("- (помилка читання активу)")
|
||||||
|
|
||||||
|
if len(lines) > _OUTPUT_MAX_LINES:
|
||||||
|
lines = lines[:_OUTPUT_MAX_LINES]
|
||||||
|
|
||||||
|
_tlog_farmos("farmos_search_assets", ok=True, reason="ok", http=True,
|
||||||
|
status=200, asset_type=asset_type, limit=limit,
|
||||||
|
filtered=bool(name_contains), count=len(lines), ms=ms)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
ms = int((time.time() - _t) * 1000)
|
||||||
|
_tlog_farmos("farmos_search_assets", ok=False, reason="timeout", err="timeout",
|
||||||
|
asset_type=asset_type, limit=limit, ms=ms)
|
||||||
|
return "FarmOS: timeout (5s)."
|
||||||
|
except Exception as exc:
|
||||||
|
ms = int((time.time() - _t) * 1000)
|
||||||
|
err_kind = _classify_exception(exc)
|
||||||
|
_tlog_farmos("farmos_search_assets", ok=False, reason=err_kind, err=err_kind,
|
||||||
|
asset_type=asset_type, limit=limit, ms=ms)
|
||||||
|
return f"FarmOS: мережна помилка ({err_kind})."
|
||||||
|
|
||||||
|
|
||||||
|
# ── Shared helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _has_auth() -> bool:
|
||||||
|
"""Перевіряє наявність будь-якого виду авторизації."""
|
||||||
|
token = os.getenv("FARMOS_TOKEN", "").strip()
|
||||||
|
farmos_user = os.getenv("FARMOS_USER", "").strip()
|
||||||
|
farmos_pass = os.getenv("FARMOS_PASS", os.getenv("FARMOS_PASSWORD", "")).strip()
|
||||||
|
return bool(token or (farmos_user and farmos_pass))
|
||||||
|
|
||||||
|
|
||||||
|
def _try_requests(url: str, headers: dict, param_sets: list, timeout: float) -> "requests.Response | None":
|
||||||
|
"""
|
||||||
|
Пробує серію наборів параметрів. Повертає першу відповідь, яка не 404,
|
||||||
|
або None якщо всі 404.
|
||||||
|
"""
|
||||||
|
resp = None
|
||||||
|
for params in param_sets:
|
||||||
|
resp = requests.get(url, headers=headers, params=params, timeout=timeout)
|
||||||
|
if resp.status_code != 404:
|
||||||
|
return resp
|
||||||
|
return resp # остання відповідь (404 або None)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_jsonapi_list(resp: "requests.Response") -> "tuple[list, str | None]":
|
||||||
|
"""
|
||||||
|
Парсить JSON:API list відповідь. Повертає (items, error_string).
|
||||||
|
Patch 3: перевіряє що data є list; якщо ні → зрозуміла помилка.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except Exception:
|
||||||
|
return [], "FarmOS: не вдалося розібрати відповідь (не валідний JSON)."
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return [], "FarmOS: неочікуваний формат відповіді."
|
||||||
|
|
||||||
|
raw = data.get("data")
|
||||||
|
if raw is None:
|
||||||
|
return [], None # порожня відповідь — не помилка
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return [], "FarmOS: неочікуваний формат відповіді (data не є списком)."
|
||||||
|
|
||||||
|
return raw, None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_notes(raw: str, max_len: int = 80) -> str:
|
||||||
|
"""
|
||||||
|
Patch 1: normalize whitespace (tabs, newlines, multiple spaces → single space),
|
||||||
|
потім обрізає по межі слова (не посеред UTF-8 символу).
|
||||||
|
"""
|
||||||
|
normalized = re.sub(r"\s+", " ", raw).strip()
|
||||||
|
if len(normalized) <= max_len:
|
||||||
|
return normalized
|
||||||
|
truncated = normalized[:max_len]
|
||||||
|
# Обріз по останньому пробілу — не посеред слова
|
||||||
|
boundary = truncated.rfind(" ")
|
||||||
|
if boundary > max_len // 2:
|
||||||
|
return truncated[:boundary]
|
||||||
|
return truncated
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_name(attrs: dict, fallback: str = "") -> str:
|
||||||
|
"""Patch 2: best-effort name з різних полів JSON:API."""
|
||||||
|
raw = (
|
||||||
|
attrs.get("name")
|
||||||
|
or attrs.get("label")
|
||||||
|
or attrs.get("type")
|
||||||
|
or fallback
|
||||||
|
)
|
||||||
|
return re.sub(r"\s+", " ", str(raw)).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_label(item: dict) -> str:
|
||||||
|
"""Отримує label/name активу з JSON:API item."""
|
||||||
|
attrs = item.get("attributes", {}) if isinstance(item, dict) else {}
|
||||||
|
raw = (
|
||||||
|
attrs.get("label")
|
||||||
|
or attrs.get("name")
|
||||||
|
or attrs.get("type")
|
||||||
|
or "(no label)"
|
||||||
|
)
|
||||||
|
return re.sub(r"\s+", " ", str(raw)).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_date(attrs: dict) -> str:
|
||||||
|
"""Patch 2: best-effort date з timestamp/changed/created."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
for field in ("timestamp", "changed", "created"):
|
||||||
|
ts = attrs.get(field)
|
||||||
|
if ts is None:
|
||||||
|
continue
|
||||||
|
if isinstance(ts, (int, float)) and ts > 0:
|
||||||
|
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
if isinstance(ts, str) and ts:
|
||||||
|
return ts[:10]
|
||||||
|
return "—"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Telemetry helper ──────────────────────────────────────────────────────────
|
||||||
|
def _tlog_farmos(
|
||||||
|
event: str,
|
||||||
|
ok: bool,
|
||||||
|
reason: str,
|
||||||
|
status: int = 0,
|
||||||
|
http: bool = False,
|
||||||
|
err: str = "",
|
||||||
|
log_type: str = "",
|
||||||
|
asset_type: str = "",
|
||||||
|
limit: int = 0,
|
||||||
|
filtered: bool = False,
|
||||||
|
count: int = -1,
|
||||||
|
ms: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""PII-safe telemetry. Без URL, токенів, user_id."""
|
||||||
|
try:
|
||||||
|
extra = f" http={http} status={status}" if http else ""
|
||||||
|
extra += f" err={err}" if err else ""
|
||||||
|
extra += f" log_type={log_type}" if log_type else ""
|
||||||
|
extra += f" asset_type={asset_type}" if asset_type else ""
|
||||||
|
extra += f" limit={limit}" if limit else ""
|
||||||
|
extra += f" filtered={filtered}" if filtered else ""
|
||||||
|
extra += f" count={count}" if count >= 0 else ""
|
||||||
|
logger.info(
|
||||||
|
"AGX_STEPAN_METRIC %s ok=%s reason=%s%s ms=%s",
|
||||||
|
event, ok, reason, extra, ms,
|
||||||
|
)
|
||||||
|
audit_tool_call(
|
||||||
|
f"tool_farmos_read.{event}",
|
||||||
|
{"reason": reason, "log_type": log_type, "asset_type": asset_type},
|
||||||
|
{"ok": ok, "status": status},
|
||||||
|
ok,
|
||||||
|
ms,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def get_asset(asset_id: str):
|
def get_asset(asset_id: str):
|
||||||
_t = time.time()
|
_t = time.time()
|
||||||
@@ -29,7 +560,7 @@ def search_assets(name_contains: str = "", limit: int = 10):
|
|||||||
r = requests.get(url, headers=_auth_headers(), params=params, timeout=20)
|
r = requests.get(url, headers=_auth_headers(), params=params, timeout=20)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
out = r.json()
|
out = r.json()
|
||||||
audit_tool_call("tool_farmos_read.get_asset", {"asset_id": asset_id}, {"ok": True}, True, int((time.time()-_t)*1000))
|
audit_tool_call("tool_farmos_read.search_assets", {"name_contains": name_contains}, {"ok": True}, True, int((time.time()-_t)*1000))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -40,7 +571,7 @@ def read_logs(log_type: str = "observation", limit: int = 10):
|
|||||||
r = requests.get(url, headers=_auth_headers(), params=params, timeout=20)
|
r = requests.get(url, headers=_auth_headers(), params=params, timeout=20)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
out = r.json()
|
out = r.json()
|
||||||
audit_tool_call("tool_farmos_read.get_asset", {"asset_id": asset_id}, {"ok": True}, True, int((time.time()-_t)*1000))
|
audit_tool_call("tool_farmos_read.read_logs", {"log_type": log_type, "limit": limit}, {"ok": True}, True, int((time.time()-_t)*1000))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -51,5 +582,5 @@ def read_inventory(limit: int = 10):
|
|||||||
r = requests.get(url, headers=_auth_headers(), params=params, timeout=20)
|
r = requests.get(url, headers=_auth_headers(), params=params, timeout=20)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
out = r.json()
|
out = r.json()
|
||||||
audit_tool_call("tool_farmos_read.get_asset", {"asset_id": asset_id}, {"ok": True}, True, int((time.time()-_t)*1000))
|
audit_tool_call("tool_farmos_read.read_inventory", {"limit": limit}, {"ok": True}, True, int((time.time()-_t)*1000))
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -11,20 +11,20 @@ node:
|
|||||||
# LLM Profiles (використовуємо лише доступні qwen3 моделі)
|
# LLM Profiles (використовуємо лише доступні qwen3 моделі)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
llm_profiles:
|
llm_profiles:
|
||||||
local_qwen3_8b:
|
local_default_coder:
|
||||||
provider: ollama
|
provider: ollama
|
||||||
base_url: http://172.17.0.1:11434
|
base_url: http://172.17.0.1:11434
|
||||||
model: qwen3:8b
|
model: qwen3:14b
|
||||||
max_tokens: 1024
|
max_tokens: 1024
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
top_p: 0.9
|
top_p: 0.9
|
||||||
timeout_ms: 30000
|
timeout_ms: 30000
|
||||||
description: "Базова qwen3:8b для інфраструктурних задач"
|
description: "Базова qwen3:14b для інфраструктурних задач"
|
||||||
|
|
||||||
qwen3_strategist_8b:
|
qwen3_strategist_8b:
|
||||||
provider: ollama
|
provider: ollama
|
||||||
base_url: http://172.17.0.1:11434
|
base_url: http://172.17.0.1:11434
|
||||||
model: qwen3:8b
|
model: qwen3:14b
|
||||||
max_tokens: 2048
|
max_tokens: 2048
|
||||||
temperature: 0.15
|
temperature: 0.15
|
||||||
top_p: 0.7
|
top_p: 0.7
|
||||||
@@ -34,7 +34,7 @@ llm_profiles:
|
|||||||
qwen3_support_8b:
|
qwen3_support_8b:
|
||||||
provider: ollama
|
provider: ollama
|
||||||
base_url: http://172.17.0.1:11434
|
base_url: http://172.17.0.1:11434
|
||||||
model: qwen3:8b
|
model: qwen3:14b
|
||||||
max_tokens: 1536
|
max_tokens: 1536
|
||||||
temperature: 0.35
|
temperature: 0.35
|
||||||
top_p: 0.88
|
top_p: 0.88
|
||||||
@@ -44,7 +44,7 @@ llm_profiles:
|
|||||||
qwen3_science_8b:
|
qwen3_science_8b:
|
||||||
provider: ollama
|
provider: ollama
|
||||||
base_url: http://172.17.0.1:11434
|
base_url: http://172.17.0.1:11434
|
||||||
model: qwen3:8b
|
model: qwen3:14b
|
||||||
max_tokens: 2048
|
max_tokens: 2048
|
||||||
temperature: 0.1
|
temperature: 0.1
|
||||||
top_p: 0.65
|
top_p: 0.65
|
||||||
@@ -54,17 +54,28 @@ llm_profiles:
|
|||||||
qwen3_creative_8b:
|
qwen3_creative_8b:
|
||||||
provider: ollama
|
provider: ollama
|
||||||
base_url: http://172.17.0.1:11434
|
base_url: http://172.17.0.1:11434
|
||||||
model: qwen3:8b
|
model: qwen3:14b
|
||||||
max_tokens: 2048
|
max_tokens: 2048
|
||||||
temperature: 0.6
|
temperature: 0.6
|
||||||
top_p: 0.92
|
top_p: 0.92
|
||||||
timeout_ms: 32000
|
timeout_ms: 32000
|
||||||
description: "Комʼюніті та мультимодальні агенти (Soul, EONARCH)"
|
description: "Комʼюніті та мультимодальні агенти (Soul, EONARCH)"
|
||||||
|
|
||||||
|
qwen3_5_35b_a3b:
|
||||||
|
provider: openai
|
||||||
|
base_url: http://host.docker.internal:11435
|
||||||
|
api_key_env: LLAMA_SERVER_API_KEY
|
||||||
|
model: qwen3.5:35b-a3b
|
||||||
|
max_tokens: 512
|
||||||
|
temperature: 0.2
|
||||||
|
top_p: 0.85
|
||||||
|
timeout_ms: 300000
|
||||||
|
description: "Qwen3.5 35B A3B для складних reasoning задач Sofiia"
|
||||||
|
|
||||||
qwen3_vision_8b:
|
qwen3_vision_8b:
|
||||||
provider: ollama
|
provider: ollama
|
||||||
base_url: http://172.17.0.1:11434
|
base_url: http://172.17.0.1:11434
|
||||||
model: qwen3-vl:8b
|
model: llava:13b
|
||||||
max_tokens: 2048
|
max_tokens: 2048
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
top_p: 0.9
|
top_p: 0.9
|
||||||
@@ -74,22 +85,22 @@ llm_profiles:
|
|||||||
qwen2_5_3b_service:
|
qwen2_5_3b_service:
|
||||||
provider: ollama
|
provider: ollama
|
||||||
base_url: http://172.17.0.1:11434
|
base_url: http://172.17.0.1:11434
|
||||||
model: qwen2.5:3b-instruct-q4_K_M
|
model: qwen3:14b
|
||||||
max_tokens: 768
|
max_tokens: 768
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
top_p: 0.85
|
top_p: 0.85
|
||||||
timeout_ms: 20000
|
timeout_ms: 20000
|
||||||
description: "Легка qwen2.5 3B для службових повідомлень та monitor ботів"
|
description: "Стабільна qwen3:14b для службових повідомлень та monitor ботів"
|
||||||
|
|
||||||
mistral_community_7b:
|
mistral_community_12b:
|
||||||
provider: ollama
|
provider: ollama
|
||||||
base_url: http://172.17.0.1:11434
|
base_url: http://172.17.0.1:11434
|
||||||
model: mistral:7b-instruct
|
model: mistral-nemo:12b
|
||||||
max_tokens: 2048
|
max_tokens: 2048
|
||||||
temperature: 0.35
|
temperature: 0.35
|
||||||
top_p: 0.9
|
top_p: 0.9
|
||||||
timeout_ms: 32000
|
timeout_ms: 32000
|
||||||
description: "Mistral 7B для CRM/community агентів (GREENFOOD, CLAN, SOUL, EONARCH)"
|
description: "Mistral Nemo 12B для CRM/community агентів"
|
||||||
|
|
||||||
cloud_deepseek:
|
cloud_deepseek:
|
||||||
provider: deepseek
|
provider: deepseek
|
||||||
@@ -132,7 +143,7 @@ orchestrator_providers:
|
|||||||
agents:
|
agents:
|
||||||
devtools:
|
devtools:
|
||||||
description: "DevTools Agent - помічник з кодом, тестами й інфраструктурою"
|
description: "DevTools Agent - помічник з кодом, тестами й інфраструктурою"
|
||||||
default_llm: local_qwen3_8b
|
default_llm: local_default_coder
|
||||||
system_prompt: |
|
system_prompt: |
|
||||||
Ти - DevTools Agent в екосистемі DAARION.city.
|
Ти - DevTools Agent в екосистемі DAARION.city.
|
||||||
Ти допомагаєш розробникам з:
|
Ти допомагаєш розробникам з:
|
||||||
@@ -178,7 +189,7 @@ agents:
|
|||||||
|
|
||||||
greenfood:
|
greenfood:
|
||||||
description: "GREENFOOD Assistant - ERP orchestrator"
|
description: "GREENFOOD Assistant - ERP orchestrator"
|
||||||
default_llm: mistral_community_7b
|
default_llm: qwen3_support_8b
|
||||||
system_prompt: |
|
system_prompt: |
|
||||||
Ти — GREENFOOD Assistant, фронтовий оркестратор ERP-системи для крафтових виробників.
|
Ти — GREENFOOD Assistant, фронтовий оркестратор ERP-системи для крафтових виробників.
|
||||||
Розумій, хто з тобою говорить (комітент, покупець, склад, бухгалтер), та делегуй задачі відповідним під-агентам.
|
Розумій, хто з тобою говорить (комітент, покупець, склад, бухгалтер), та делегуй задачі відповідним під-агентам.
|
||||||
@@ -217,7 +228,7 @@ agents:
|
|||||||
|
|
||||||
clan:
|
clan:
|
||||||
description: "CLAN — комунікації кооперативів"
|
description: "CLAN — комунікації кооперативів"
|
||||||
default_llm: mistral_community_7b
|
default_llm: qwen3_support_8b
|
||||||
system_prompt: |
|
system_prompt: |
|
||||||
Ти — CLAN, координуєш комунікацію, оголошення та community operations.
|
Ти — CLAN, координуєш комунікацію, оголошення та community operations.
|
||||||
Відповідай лише коли тема стосується координації, а звернення адресовано тобі (тег @ClanBot чи згадка кланів).
|
Відповідай лише коли тема стосується координації, а звернення адресовано тобі (тег @ClanBot чи згадка кланів).
|
||||||
@@ -225,7 +236,7 @@ agents:
|
|||||||
|
|
||||||
soul:
|
soul:
|
||||||
description: "SOUL / Spirit — духовний гід комʼюніті"
|
description: "SOUL / Spirit — духовний гід комʼюніті"
|
||||||
default_llm: mistral_community_7b
|
default_llm: qwen3_support_8b
|
||||||
system_prompt: |
|
system_prompt: |
|
||||||
Ти — Spirit/SOUL, ментор живої операційної системи.
|
Ти — Spirit/SOUL, ментор живої операційної системи.
|
||||||
Пояснюй місію, підтримуй мораль, працюй із soft-skills.
|
Пояснюй місію, підтримуй мораль, працюй із soft-skills.
|
||||||
@@ -298,7 +309,7 @@ agents:
|
|||||||
|
|
||||||
eonarch:
|
eonarch:
|
||||||
description: "EONARCH — мультимодальний агент (vision + chat)"
|
description: "EONARCH — мультимодальний агент (vision + chat)"
|
||||||
default_llm: mistral_community_7b
|
default_llm: qwen3_support_8b
|
||||||
system_prompt: |
|
system_prompt: |
|
||||||
Ти — EONARCH, аналізуєш зображення, PDF та текстові запити.
|
Ти — EONARCH, аналізуєш зображення, PDF та текстові запити.
|
||||||
Враховуй присутність інших ботів та працюй лише за прямим тегом або коли потрібно мультимодальне тлумачення.
|
Враховуй присутність інших ботів та працюй лише за прямим тегом або коли потрібно мультимодальне тлумачення.
|
||||||
@@ -403,6 +414,15 @@ agents:
|
|||||||
Ти — Yaromir Crew. Стратегія, наставництво, психологічна підтримка команди.
|
Ти — Yaromir Crew. Стратегія, наставництво, психологічна підтримка команди.
|
||||||
Розрізняй інших ботів за ніком та відповідай лише на стратегічні запити.
|
Розрізняй інших ботів за ніком та відповідай лише на стратегічні запити.
|
||||||
|
|
||||||
|
sofiia:
|
||||||
|
description: "Sofiia — Chief AI Architect та Technical Sovereign"
|
||||||
|
default_llm: local_default_coder
|
||||||
|
system_prompt: |
|
||||||
|
Ти Sofiia — Chief AI Architect та Technical Sovereign екосистеми DAARION.city.
|
||||||
|
Працюй як CTO-помічник: архітектура, reliability, безпека, release governance, incident/risk/backlog контроль.
|
||||||
|
Відповідай українською, структуровано і коротко; не вигадуй факти, якщо даних нема — кажи прямо.
|
||||||
|
Для задач про інфраструктуру пріоритет: перевірка health/monitor, далі конкретні дії і верифікація.
|
||||||
|
|
||||||
monitor:
|
monitor:
|
||||||
description: "Monitor Agent - архітектор-інспектор DAGI"
|
description: "Monitor Agent - архітектор-інспектор DAGI"
|
||||||
default_llm: qwen2_5_3b_service
|
default_llm: qwen2_5_3b_service
|
||||||
@@ -423,21 +443,21 @@ routing:
|
|||||||
priority: 10
|
priority: 10
|
||||||
when:
|
when:
|
||||||
mode: chat
|
mode: chat
|
||||||
use_llm: local_qwen3_8b
|
use_llm: local_default_coder
|
||||||
description: "microDAO chat → local qwen3"
|
description: "microDAO chat → local qwen3"
|
||||||
|
|
||||||
- id: qa_build_mode
|
- id: qa_build_mode
|
||||||
priority: 8
|
priority: 8
|
||||||
when:
|
when:
|
||||||
mode: qa_build
|
mode: qa_build
|
||||||
use_llm: local_qwen3_8b
|
use_llm: local_default_coder
|
||||||
description: "Q&A generation from parsed docs"
|
description: "Q&A generation from parsed docs"
|
||||||
|
|
||||||
- id: rag_query_mode
|
- id: rag_query_mode
|
||||||
priority: 7
|
priority: 7
|
||||||
when:
|
when:
|
||||||
mode: rag_query
|
mode: rag_query
|
||||||
use_llm: local_qwen3_8b
|
use_llm: local_default_coder
|
||||||
description: "RAG query with Memory"
|
description: "RAG query with Memory"
|
||||||
|
|
||||||
- id: crew_mode
|
- id: crew_mode
|
||||||
@@ -522,7 +542,7 @@ routing:
|
|||||||
priority: 20
|
priority: 20
|
||||||
when:
|
when:
|
||||||
agent: devtools
|
agent: devtools
|
||||||
use_llm: local_qwen3_8b
|
use_llm: local_default_coder
|
||||||
description: "Будь-які інші DevTools задачі"
|
description: "Будь-які інші DevTools задачі"
|
||||||
|
|
||||||
- id: microdao_orchestrator_agent
|
- id: microdao_orchestrator_agent
|
||||||
@@ -545,7 +565,7 @@ routing:
|
|||||||
priority: 5
|
priority: 5
|
||||||
when:
|
when:
|
||||||
agent: greenfood
|
agent: greenfood
|
||||||
use_llm: mistral_community_7b
|
use_llm: qwen3_support_8b
|
||||||
use_context_prompt: true
|
use_context_prompt: true
|
||||||
description: "GREENFOOD ERP"
|
description: "GREENFOOD ERP"
|
||||||
|
|
||||||
@@ -569,7 +589,7 @@ routing:
|
|||||||
priority: 5
|
priority: 5
|
||||||
when:
|
when:
|
||||||
agent: clan
|
agent: clan
|
||||||
use_llm: mistral_community_7b
|
use_llm: qwen3_support_8b
|
||||||
use_context_prompt: true
|
use_context_prompt: true
|
||||||
description: "CLAN community operations"
|
description: "CLAN community operations"
|
||||||
|
|
||||||
@@ -577,7 +597,7 @@ routing:
|
|||||||
priority: 5
|
priority: 5
|
||||||
when:
|
when:
|
||||||
agent: soul
|
agent: soul
|
||||||
use_llm: mistral_community_7b
|
use_llm: qwen3_support_8b
|
||||||
use_context_prompt: true
|
use_context_prompt: true
|
||||||
description: "SOUL / Spirit мотивація"
|
description: "SOUL / Spirit мотивація"
|
||||||
|
|
||||||
@@ -601,7 +621,7 @@ routing:
|
|||||||
priority: 5
|
priority: 5
|
||||||
when:
|
when:
|
||||||
agent: eonarch
|
agent: eonarch
|
||||||
use_llm: mistral_community_7b
|
use_llm: qwen3_support_8b
|
||||||
use_context_prompt: true
|
use_context_prompt: true
|
||||||
description: "EONARCH vision"
|
description: "EONARCH vision"
|
||||||
|
|
||||||
@@ -633,7 +653,7 @@ routing:
|
|||||||
- id: fallback_local
|
- id: fallback_local
|
||||||
priority: 100
|
priority: 100
|
||||||
when: {}
|
when: {}
|
||||||
use_llm: local_qwen3_8b
|
use_llm: local_default_coder
|
||||||
description: "Fallback: всі інші запити → базова qwen3"
|
description: "Fallback: всі інші запити → базова qwen3"
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ FROM python:3.11-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
espeak-ng \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
@@ -16,7 +20,7 @@ ENV PYTHONUNBUFFERED=1
|
|||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
CMD curl -f http://localhost:8000/health || exit 1
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=3)" || exit 1
|
||||||
|
|
||||||
# Run
|
# Run
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import httpx
|
|||||||
import hashlib
|
import hashlib
|
||||||
from fastapi import FastAPI, HTTPException, Query
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from pathlib import Path as FilePath
|
||||||
|
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .models import (
|
from .models import (
|
||||||
@@ -27,6 +30,8 @@ from .models import (
|
|||||||
from .vector_store import vector_store
|
from .vector_store import vector_store
|
||||||
from .database import db
|
from .database import db
|
||||||
from .auth import get_current_service, get_current_service_optional
|
from .auth import get_current_service, get_current_service_optional
|
||||||
|
from .integration_endpoints import router as integration_router
|
||||||
|
from .voice_endpoints import router as voice_router
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -199,6 +204,19 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.include_router(integration_router)
|
||||||
|
app.include_router(voice_router)
|
||||||
|
|
||||||
|
STATIC_DIR = FilePath(__file__).parent.parent / "static"
|
||||||
|
|
||||||
|
@app.get("/ui")
|
||||||
|
async def serve_ui():
|
||||||
|
"""Serve Sofiia UI"""
|
||||||
|
ui_file = STATIC_DIR / "sofiia-ui.html"
|
||||||
|
if ui_file.exists():
|
||||||
|
return FileResponse(ui_file)
|
||||||
|
raise HTTPException(status_code=404, detail="UI not found")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# HEALTH
|
# HEALTH
|
||||||
|
|||||||
@@ -310,15 +310,29 @@ class VectorStore:
|
|||||||
|
|
||||||
async def get_collection_stats(self) -> Dict[str, Any]:
|
async def get_collection_stats(self) -> Dict[str, Any]:
|
||||||
"""Get collection statistics"""
|
"""Get collection statistics"""
|
||||||
memories_info = self.client.get_collection(self.memories_collection)
|
try:
|
||||||
|
memories_info = self.client.get_collection(self.memories_collection)
|
||||||
return {
|
|
||||||
"memories": {
|
points_count = getattr(memories_info, 'points_count', 0)
|
||||||
"points_count": memories_info.points_count,
|
vectors_count = getattr(memories_info, 'vectors_count', points_count)
|
||||||
"vectors_count": memories_info.vectors_count,
|
indexed_vectors_count = getattr(memories_info, 'indexed_vectors_count', 0)
|
||||||
"indexed_vectors_count": memories_info.indexed_vectors_count
|
|
||||||
|
return {
|
||||||
|
"memories": {
|
||||||
|
"points_count": points_count,
|
||||||
|
"vectors_count": vectors_count,
|
||||||
|
"indexed_vectors_count": indexed_vectors_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("get_collection_stats_failed", error=str(e))
|
||||||
|
return {
|
||||||
|
"memories": {
|
||||||
|
"points_count": 0,
|
||||||
|
"vectors_count": 0,
|
||||||
|
"indexed_vectors_count": 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
# Global instance
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ sqlalchemy[asyncio]==2.0.25
|
|||||||
alembic==1.13.1
|
alembic==1.13.1
|
||||||
|
|
||||||
# Vector database
|
# Vector database
|
||||||
qdrant-client==1.7.3
|
qdrant-client==1.12.1
|
||||||
|
|
||||||
# Embeddings
|
# Embeddings
|
||||||
cohere==4.44
|
cohere==4.44
|
||||||
@@ -24,10 +24,15 @@ httpx==0.26.0
|
|||||||
tenacity==8.2.3
|
tenacity==8.2.3
|
||||||
structlog==24.1.0
|
structlog==24.1.0
|
||||||
PyJWT==2.8.0
|
PyJWT==2.8.0
|
||||||
|
python-multipart==0.0.9
|
||||||
|
|
||||||
# Token counting
|
# Token counting
|
||||||
tiktoken==0.5.2
|
tiktoken==0.5.2
|
||||||
|
|
||||||
|
# Voice stack
|
||||||
|
edge-tts==6.1.19
|
||||||
|
faster-whisper==1.1.1
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
pytest==7.4.4
|
pytest==7.4.4
|
||||||
pytest-asyncio==0.23.3
|
pytest-asyncio==0.23.3
|
||||||
|
|||||||
@@ -275,6 +275,7 @@ async def report_latency_endpoint(request: Request):
|
|||||||
ENABLE_NATS = os.getenv("ENABLE_NATS_CAPS", "false").lower() in ("true", "1", "yes")
|
ENABLE_NATS = os.getenv("ENABLE_NATS_CAPS", "false").lower() in ("true", "1", "yes")
|
||||||
NATS_URL = os.getenv("NATS_URL", "nats://dagi-nats:4222")
|
NATS_URL = os.getenv("NATS_URL", "nats://dagi-nats:4222")
|
||||||
NATS_SUBJECT = f"node.{NODE_ID.lower()}.capabilities.get"
|
NATS_SUBJECT = f"node.{NODE_ID.lower()}.capabilities.get"
|
||||||
|
NATS_BROADCAST_SUBJECT = "fabric.capabilities.discover"
|
||||||
|
|
||||||
_nats_client = None
|
_nats_client = None
|
||||||
|
|
||||||
@@ -304,7 +305,8 @@ async def startup_nats():
|
|||||||
import nats as nats_lib
|
import nats as nats_lib
|
||||||
_nats_client = await nats_lib.connect(NATS_URL)
|
_nats_client = await nats_lib.connect(NATS_URL)
|
||||||
await _nats_client.subscribe(NATS_SUBJECT, cb=_nats_capabilities_handler)
|
await _nats_client.subscribe(NATS_SUBJECT, cb=_nats_capabilities_handler)
|
||||||
logger.info(f"✅ NATS subscribed: {NATS_SUBJECT} on {NATS_URL}")
|
await _nats_client.subscribe(NATS_BROADCAST_SUBJECT, cb=_nats_capabilities_handler)
|
||||||
|
logger.info(f"✅ NATS subscribed: {NATS_SUBJECT} + {NATS_BROADCAST_SUBJECT} on {NATS_URL}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"⚠️ NATS init failed (non-fatal): {e}")
|
logger.warning(f"⚠️ NATS init failed (non-fatal): {e}")
|
||||||
_nats_client = None
|
_nats_client = None
|
||||||
|
|||||||
@@ -2,9 +2,19 @@
|
|||||||
Per-agent tool configuration.
|
Per-agent tool configuration.
|
||||||
All agents have FULL standard stack + specialized tools.
|
All agents have FULL standard stack + specialized tools.
|
||||||
Each agent is a platform with own site, channels, database, users.
|
Each agent is a platform with own site, channels, database, users.
|
||||||
|
|
||||||
|
v2: Supports default_tools merge policy via tools_rollout.yml config.
|
||||||
|
Effective tools = unique(DEFAULT_TOOLS_BY_ROLE ∪ agent.tools ∪ agent.capability_tools)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# FULL standard stack - available to ALL agents
|
import os
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# FULL standard stack - available to ALL agents (legacy explicit list, kept for compatibility)
|
||||||
FULL_STANDARD_STACK = [
|
FULL_STANDARD_STACK = [
|
||||||
# Search & Knowledge (Priority 1)
|
# Search & Knowledge (Priority 1)
|
||||||
"memory_search",
|
"memory_search",
|
||||||
@@ -29,59 +39,74 @@ FULL_STANDARD_STACK = [
|
|||||||
|
|
||||||
# File artifacts
|
# File artifacts
|
||||||
"file_tool",
|
"file_tool",
|
||||||
|
|
||||||
|
# Repo Tool (read-only filesystem)
|
||||||
|
"repo_tool",
|
||||||
|
|
||||||
|
# PR Reviewer Tool
|
||||||
|
"pr_reviewer_tool",
|
||||||
|
|
||||||
|
# Contract Tool (OpenAPI/JSON Schema)
|
||||||
|
"contract_tool",
|
||||||
|
|
||||||
|
# Oncall/Runbook Tool
|
||||||
|
"oncall_tool",
|
||||||
|
|
||||||
|
# Observability Tool
|
||||||
|
"observability_tool",
|
||||||
|
|
||||||
|
# Config Linter Tool (secrets, policy)
|
||||||
|
"config_linter_tool",
|
||||||
|
|
||||||
|
# ThreatModel Tool (security analysis)
|
||||||
|
"threatmodel_tool",
|
||||||
|
|
||||||
|
# Job Orchestrator Tool (ops tasks)
|
||||||
|
"job_orchestrator_tool",
|
||||||
|
|
||||||
|
# Knowledge Base Tool (ADR, docs, runbooks)
|
||||||
|
"kb_tool",
|
||||||
|
|
||||||
|
# Drift Analyzer Tool (service/openapi/nats/tools drift)
|
||||||
|
"drift_analyzer_tool",
|
||||||
|
|
||||||
|
# Pieces OS integration
|
||||||
|
"pieces_tool",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Specialized tools per agent (on top of standard stack)
|
# Specialized tools per agent (on top of standard stack)
|
||||||
AGENT_SPECIALIZED_TOOLS = {
|
AGENT_SPECIALIZED_TOOLS = {
|
||||||
# Helion - Energy platform
|
# Helion - Energy platform
|
||||||
# Specialized: energy calculations, solar/wind analysis
|
|
||||||
"helion": ['comfy_generate_image', 'comfy_generate_video'],
|
"helion": ['comfy_generate_image', 'comfy_generate_video'],
|
||||||
|
|
||||||
# Alateya - R&D Lab OS
|
# Alateya - R&D Lab OS
|
||||||
# Specialized: experiment tracking, hypothesis testing
|
|
||||||
"alateya": ['comfy_generate_image', 'comfy_generate_video'],
|
"alateya": ['comfy_generate_image', 'comfy_generate_video'],
|
||||||
|
|
||||||
# Nutra - Health & Nutrition
|
# Nutra - Health & Nutrition
|
||||||
# Specialized: nutrition calculations, supplement analysis
|
|
||||||
"nutra": ['comfy_generate_image', 'comfy_generate_video'],
|
"nutra": ['comfy_generate_image', 'comfy_generate_video'],
|
||||||
|
|
||||||
# AgroMatrix - Agriculture
|
# AgroMatrix - Agriculture
|
||||||
# Specialized: crop analysis, weather integration, field mapping + plant intelligence
|
"agromatrix": ['comfy_generate_image', 'comfy_generate_video'],
|
||||||
"agromatrix": [
|
|
||||||
'comfy_generate_image',
|
|
||||||
'comfy_generate_video',
|
|
||||||
'plantnet_lookup',
|
|
||||||
'nature_id_identify',
|
|
||||||
'gbif_species_lookup',
|
|
||||||
'agrovoc_lookup',
|
|
||||||
],
|
|
||||||
|
|
||||||
# GreenFood - Food & Eco
|
# GreenFood - Food & Eco
|
||||||
# Specialized: recipe analysis, eco-scoring
|
|
||||||
"greenfood": ['comfy_generate_image', 'comfy_generate_video'],
|
"greenfood": ['comfy_generate_image', 'comfy_generate_video'],
|
||||||
|
|
||||||
# Druid - Knowledge Search
|
# Druid - Knowledge Search
|
||||||
# Specialized: deep RAG, document comparison
|
|
||||||
"druid": ['comfy_generate_image', 'comfy_generate_video'],
|
"druid": ['comfy_generate_image', 'comfy_generate_video'],
|
||||||
|
|
||||||
# DaarWizz - DAO Coordination
|
# DaarWizz - DAO Coordination
|
||||||
# Specialized: governance tools, voting, treasury
|
|
||||||
"daarwizz": ['comfy_generate_image', 'comfy_generate_video'],
|
"daarwizz": ['comfy_generate_image', 'comfy_generate_video'],
|
||||||
|
|
||||||
# Clan - Community
|
# Clan - Community
|
||||||
# Specialized: event management, polls, member tracking
|
|
||||||
"clan": ['comfy_generate_image', 'comfy_generate_video'],
|
"clan": ['comfy_generate_image', 'comfy_generate_video'],
|
||||||
|
|
||||||
# Eonarch - Philosophy & Evolution
|
# Eonarch - Philosophy & Evolution
|
||||||
# Specialized: concept mapping, timeline analysis
|
|
||||||
"eonarch": ['comfy_generate_image', 'comfy_generate_video'],
|
"eonarch": ['comfy_generate_image', 'comfy_generate_video'],
|
||||||
|
|
||||||
# SenpAI (Gordon Senpai) - Trading & Markets
|
# SenpAI (Gordon Senpai) - Trading & Markets
|
||||||
# Specialized: real-time market data, features, signals
|
|
||||||
"senpai": ['market_data', 'comfy_generate_image', 'comfy_generate_video'],
|
"senpai": ['market_data', 'comfy_generate_image', 'comfy_generate_video'],
|
||||||
|
|
||||||
# 1OK - Window Master Assistant
|
# 1OK - Window Master Assistant
|
||||||
# Specialized: CRM flow, quoting, PDF docs, scheduling
|
|
||||||
"oneok": [
|
"oneok": [
|
||||||
"crm_search_client",
|
"crm_search_client",
|
||||||
"crm_upsert_client",
|
"crm_upsert_client",
|
||||||
@@ -104,7 +129,32 @@ AGENT_SPECIALIZED_TOOLS = {
|
|||||||
"yaromir": ['comfy_generate_image', 'comfy_generate_video'],
|
"yaromir": ['comfy_generate_image', 'comfy_generate_video'],
|
||||||
|
|
||||||
# Sofiia - Chief AI Architect
|
# Sofiia - Chief AI Architect
|
||||||
"sofiia": ['comfy_generate_image', 'comfy_generate_video'],
|
"sofiia": [
|
||||||
|
'comfy_generate_image',
|
||||||
|
'comfy_generate_video',
|
||||||
|
'risk_engine_tool',
|
||||||
|
'architecture_pressure_tool',
|
||||||
|
'backlog_tool',
|
||||||
|
'job_orchestrator_tool',
|
||||||
|
'dependency_scanner_tool',
|
||||||
|
'incident_intelligence_tool',
|
||||||
|
'cost_analyzer_tool',
|
||||||
|
'pieces_tool',
|
||||||
|
'notion_tool',
|
||||||
|
],
|
||||||
|
|
||||||
|
# Admin - platform operations
|
||||||
|
"admin": [
|
||||||
|
'risk_engine_tool',
|
||||||
|
'architecture_pressure_tool',
|
||||||
|
'backlog_tool',
|
||||||
|
'job_orchestrator_tool',
|
||||||
|
'dependency_scanner_tool',
|
||||||
|
'incident_intelligence_tool',
|
||||||
|
'cost_analyzer_tool',
|
||||||
|
'pieces_tool',
|
||||||
|
'notion_tool',
|
||||||
|
],
|
||||||
|
|
||||||
# Daarion - Media Generation
|
# Daarion - Media Generation
|
||||||
"daarion": ['comfy_generate_image', 'comfy_generate_video'],
|
"daarion": ['comfy_generate_image', 'comfy_generate_video'],
|
||||||
@@ -150,11 +200,99 @@ AGENT_CREW_TEAMS = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─── Rollout Config Loader ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_agent_tools(agent_id: str) -> list:
|
_rollout_config = None
|
||||||
"""Get all tools for an agent: standard stack + specialized."""
|
_rollout_loaded = False
|
||||||
|
|
||||||
|
|
||||||
|
def _load_rollout_config() -> dict:
|
||||||
|
"""Load tools_rollout.yml, cache on first call."""
|
||||||
|
global _rollout_config, _rollout_loaded
|
||||||
|
if _rollout_loaded:
|
||||||
|
return _rollout_config or {}
|
||||||
|
|
||||||
|
config_path = Path(__file__).parent.parent.parent / "config" / "tools_rollout.yml"
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
with open(config_path, "r") as f:
|
||||||
|
_rollout_config = yaml.safe_load(f) or {}
|
||||||
|
logger.debug(f"Loaded tools_rollout.yml: {list(_rollout_config.keys())}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not load tools_rollout.yml: {e}. Using legacy config.")
|
||||||
|
_rollout_config = {}
|
||||||
|
finally:
|
||||||
|
_rollout_loaded = True
|
||||||
|
|
||||||
|
return _rollout_config
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_group(group_ref: str, config: dict, seen: Optional[set] = None) -> List[str]:
|
||||||
|
"""Expand @group_name reference recursively. Prevents circular refs."""
|
||||||
|
if seen is None:
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
if group_ref.startswith("@"):
|
||||||
|
group_name = group_ref[1:]
|
||||||
|
if group_name in seen:
|
||||||
|
logger.warning(f"Circular group reference: {group_name}")
|
||||||
|
return []
|
||||||
|
seen.add(group_name)
|
||||||
|
group_tools = config.get(group_name, [])
|
||||||
|
result = []
|
||||||
|
for item in group_tools:
|
||||||
|
result.extend(_expand_group(item, config, seen))
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
return [group_ref]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_role_tools(agent_id: str, config: dict) -> List[str]:
|
||||||
|
"""Get tools for agent's role via rollout config."""
|
||||||
|
agent_roles = config.get("agent_roles", {})
|
||||||
|
role = agent_roles.get(agent_id, "agent_default")
|
||||||
|
|
||||||
|
role_map = config.get("role_map", {})
|
||||||
|
role_config = role_map.get(role, role_map.get("agent_default", {}))
|
||||||
|
role_tool_refs = role_config.get("tools", [])
|
||||||
|
|
||||||
|
tools = []
|
||||||
|
for ref in role_tool_refs:
|
||||||
|
tools.extend(_expand_group(ref, config))
|
||||||
|
return tools
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_tools(agent_id: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
Get all tools for an agent using merge policy:
|
||||||
|
effective_tools = unique(DEFAULT_TOOLS_BY_ROLE ∪ FULL_STANDARD_STACK ∪ agent.specialized_tools)
|
||||||
|
|
||||||
|
- First try rollout config for role-based tools.
|
||||||
|
- Always union with FULL_STANDARD_STACK for backward compat.
|
||||||
|
- Always add agent-specific specialized tools.
|
||||||
|
- Stable order: role_tools → standard_stack → specialized (deduped).
|
||||||
|
"""
|
||||||
|
rollout = _load_rollout_config()
|
||||||
|
|
||||||
|
# 1. Role-based default tools (from rollout config)
|
||||||
|
role_tools = _get_role_tools(agent_id, rollout) if rollout else []
|
||||||
|
|
||||||
|
# 2. Legacy full standard stack (guaranteed baseline)
|
||||||
|
standard_tools = list(FULL_STANDARD_STACK)
|
||||||
|
|
||||||
|
# 3. Agent-specific specialized tools
|
||||||
specialized = AGENT_SPECIALIZED_TOOLS.get(agent_id, [])
|
specialized = AGENT_SPECIALIZED_TOOLS.get(agent_id, [])
|
||||||
return FULL_STANDARD_STACK + specialized
|
|
||||||
|
# Merge with stable order, deduplicate preserving first occurrence
|
||||||
|
merged = []
|
||||||
|
seen = set()
|
||||||
|
for tool in role_tools + standard_tools + specialized:
|
||||||
|
if tool not in seen:
|
||||||
|
merged.append(tool)
|
||||||
|
seen.add(tool)
|
||||||
|
|
||||||
|
logger.debug(f"Agent '{agent_id}' effective tools ({len(merged)}): {merged[:10]}...")
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def is_tool_allowed(agent_id: str, tool_name: str) -> bool:
|
def is_tool_allowed(agent_id: str, tool_name: str) -> bool:
|
||||||
@@ -163,6 +301,21 @@ def is_tool_allowed(agent_id: str, tool_name: str) -> bool:
|
|||||||
return tool_name in allowed
|
return tool_name in allowed
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_role(agent_id: str) -> str:
|
||||||
|
"""Get the role assigned to an agent via rollout config."""
|
||||||
|
rollout = _load_rollout_config()
|
||||||
|
agent_roles = rollout.get("agent_roles", {})
|
||||||
|
return agent_roles.get(agent_id, "agent_default")
|
||||||
|
|
||||||
|
|
||||||
def get_agent_crew(agent_id: str) -> dict:
|
def get_agent_crew(agent_id: str) -> dict:
|
||||||
"""Get CrewAI team configuration for an agent."""
|
"""Get CrewAI team configuration for an agent."""
|
||||||
return AGENT_CREW_TEAMS.get(agent_id, {"team_name": "Default", "agents": []})
|
return AGENT_CREW_TEAMS.get(agent_id, {"team_name": "Default", "agents": []})
|
||||||
|
|
||||||
|
|
||||||
|
def reload_rollout_config():
|
||||||
|
"""Force reload of tools_rollout.yml (for hot-reload/testing)."""
|
||||||
|
global _rollout_config, _rollout_loaded
|
||||||
|
_rollout_config = None
|
||||||
|
_rollout_loaded = False
|
||||||
|
return _load_rollout_config()
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ CACHE_TTL = int(os.getenv("GLOBAL_CAPS_TTL", "30"))
|
|||||||
NATS_DISCOVERY_TIMEOUT_MS = int(os.getenv("NATS_DISCOVERY_TIMEOUT_MS", "500"))
|
NATS_DISCOVERY_TIMEOUT_MS = int(os.getenv("NATS_DISCOVERY_TIMEOUT_MS", "500"))
|
||||||
NATS_ENABLED = os.getenv("ENABLE_GLOBAL_CAPS_NATS", "true").lower() in ("true", "1")
|
NATS_ENABLED = os.getenv("ENABLE_GLOBAL_CAPS_NATS", "true").lower() in ("true", "1")
|
||||||
|
|
||||||
CAPS_DISCOVERY_SUBJECT = "node.*.capabilities.get"
|
CAPS_DISCOVERY_SUBJECT = "fabric.capabilities.discover"
|
||||||
CAPS_INBOX_PREFIX = "_CAPS_REPLY"
|
CAPS_INBOX_PREFIX = "_CAPS_REPLY"
|
||||||
|
|
||||||
_node_cache: Dict[str, Dict[str, Any]] = {}
|
_node_cache: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ agents:
|
|||||||
|
|
||||||
greenfood:
|
greenfood:
|
||||||
description: "GREENFOOD Assistant - ERP orchestrator"
|
description: "GREENFOOD Assistant - ERP orchestrator"
|
||||||
default_llm: mistral_community_7b
|
default_llm: qwen3_support_8b
|
||||||
system_prompt: |
|
system_prompt: |
|
||||||
Ти — GREENFOOD Assistant, фронтовий оркестратор ERP-системи для крафтових виробників.
|
Ти — GREENFOOD Assistant, фронтовий оркестратор ERP-системи для крафтових виробників.
|
||||||
Розумій, хто з тобою говорить (комітент, покупець, склад, бухгалтер), та делегуй задачі відповідним під-агентам.
|
Розумій, хто з тобою говорить (комітент, покупець, склад, бухгалтер), та делегуй задачі відповідним під-агентам.
|
||||||
@@ -217,7 +217,7 @@ agents:
|
|||||||
|
|
||||||
clan:
|
clan:
|
||||||
description: "CLAN — комунікації кооперативів"
|
description: "CLAN — комунікації кооперативів"
|
||||||
default_llm: mistral_community_7b
|
default_llm: qwen3_support_8b
|
||||||
system_prompt: |
|
system_prompt: |
|
||||||
Ти — CLAN, координуєш комунікацію, оголошення та community operations.
|
Ти — CLAN, координуєш комунікацію, оголошення та community operations.
|
||||||
Відповідай лише коли тема стосується координації, а звернення адресовано тобі (тег @ClanBot чи згадка кланів).
|
Відповідай лише коли тема стосується координації, а звернення адресовано тобі (тег @ClanBot чи згадка кланів).
|
||||||
@@ -225,7 +225,7 @@ agents:
|
|||||||
|
|
||||||
soul:
|
soul:
|
||||||
description: "SOUL / Spirit — духовний гід комʼюніті"
|
description: "SOUL / Spirit — духовний гід комʼюніті"
|
||||||
default_llm: mistral_community_7b
|
default_llm: qwen3_support_8b
|
||||||
system_prompt: |
|
system_prompt: |
|
||||||
Ти — Spirit/SOUL, ментор живої операційної системи.
|
Ти — Spirit/SOUL, ментор живої операційної системи.
|
||||||
Пояснюй місію, підтримуй мораль, працюй із soft-skills.
|
Пояснюй місію, підтримуй мораль, працюй із soft-skills.
|
||||||
@@ -298,7 +298,7 @@ agents:
|
|||||||
|
|
||||||
eonarch:
|
eonarch:
|
||||||
description: "EONARCH — мультимодальний агент (vision + chat)"
|
description: "EONARCH — мультимодальний агент (vision + chat)"
|
||||||
default_llm: mistral_community_7b
|
default_llm: qwen3_support_8b
|
||||||
system_prompt: |
|
system_prompt: |
|
||||||
Ти — EONARCH, аналізуєш зображення, PDF та текстові запити.
|
Ти — EONARCH, аналізуєш зображення, PDF та текстові запити.
|
||||||
Враховуй присутність інших ботів та працюй лише за прямим тегом або коли потрібно мультимодальне тлумачення.
|
Враховуй присутність інших ботів та працюй лише за прямим тегом або коли потрібно мультимодальне тлумачення.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user