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:
@@ -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 json
|
||||
import time
|
||||
import threading
|
||||
import requests
|
||||
|
||||
# ── In-memory OAuth2 token cache (process-level, thread-safe) ────────────────
|
||||
_token_cache: dict = {}
|
||||
_token_lock = threading.Lock()
|
||||
|
||||
def _auth_headers():
|
||||
token = os.getenv("FARMOS_TOKEN")
|
||||
_OAUTH_TOKEN_PATH = "/oauth/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:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
user = os.getenv("FARMOS_USER")
|
||||
pwd = os.getenv("FARMOS_PASSWORD")
|
||||
if user and pwd:
|
||||
import base64
|
||||
auth = base64.b64encode(f"{user}:{pwd}".encode()).decode()
|
||||
return {"Authorization": f"Basic {auth}"}
|
||||
|
||||
# 2. OAuth2 password grant
|
||||
user = os.getenv("FARMOS_USER", "").strip()
|
||||
pwd = os.getenv("FARMOS_PASS", os.getenv("FARMOS_PASSWORD", "")).strip()
|
||||
client_id = os.getenv("FARMOS_CLIENT_ID", "farm").strip()
|
||||
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 {}
|
||||
|
||||
@@ -1,11 +1,542 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from urllib.parse import quote as _urlquote
|
||||
from .audit import audit_tool_call
|
||||
import requests
|
||||
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")
|
||||
|
||||
# ── 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):
|
||||
_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.raise_for_status()
|
||||
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
|
||||
|
||||
|
||||
@@ -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.raise_for_status()
|
||||
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
|
||||
|
||||
|
||||
@@ -51,5 +582,5 @@ def read_inventory(limit: int = 10):
|
||||
r = requests.get(url, headers=_auth_headers(), params=params, timeout=20)
|
||||
r.raise_for_status()
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user