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:
Apple
2026-02-27 03:20:13 -08:00
parent a6531507df
commit 90080c632a
28 changed files with 8883 additions and 1459 deletions

View File

@@ -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
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View File

@@ -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:

View File

@@ -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
) )

View File

@@ -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
) )

View File

@@ -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
) )

View File

@@ -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
) )

View File

@@ -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,
) )

View File

@@ -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
) )

View File

@@ -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:

View File

@@ -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

View File

@@ -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="Коротка розмовна відповідь українською, 14 речення.",
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():

View File

@@ -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

View File

@@ -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

View File

@@ -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",
] ]
} }

View File

@@ -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

View File

@@ -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 {}

View File

@@ -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

View File

@@ -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"
# ============================================================================ # ============================================================================

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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]] = {}

View File

@@ -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