snapshot: NODE1 production state 2026-02-09
Complete snapshot of /opt/microdao-daarion/ from NODE1 (144.76.224.179).
This represents the actual running production code that has diverged
significantly from the previous main branch.
Key changes from old main:
- Gateway (http_api.py): expanded from ~40KB to 164KB with full agent support
- Router: new /v1/agents/{id}/infer endpoint with vision + DeepSeek routing
- Behavior Policy: SOWA v2.2 (3-level: FULL/ACK/SILENT)
- Agent Registry: config/agent_registry.yml as single source of truth
- 13 agents configured (was 3)
- Memory service integration
- CrewAI teams and roles
Excluded from snapshot: venv/, .env, data/, backups, .tgz archives
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1
crews/agromatrix_crew/__init__.py
Normal file
1
crews/agromatrix_crew/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# AgroMatrix Crew
|
||||
16
crews/agromatrix_crew/agents/iot_agent.py
Normal file
16
crews/agromatrix_crew/agents/iot_agent.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from crewai import Agent
|
||||
from crews.agromatrix_crew import tools
|
||||
|
||||
|
||||
def build_iot():
|
||||
return Agent(
|
||||
role="IoT Agent",
|
||||
goal="Читати телеметрію ThingsBoard і публікувати події в NATS.",
|
||||
backstory="Доступ лише через ThingsBoard/NATS інструменти.",
|
||||
tools=[
|
||||
tools.tool_thingsboard_read,
|
||||
tools.tool_event_bus
|
||||
],
|
||||
allow_delegation=False,
|
||||
verbose=True
|
||||
)
|
||||
16
crews/agromatrix_crew/agents/operations_agent.py
Normal file
16
crews/agromatrix_crew/agents/operations_agent.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from crewai import Agent
|
||||
from crews.agromatrix_crew import tools
|
||||
|
||||
|
||||
def build_operations():
|
||||
return Agent(
|
||||
role="Operations Agent",
|
||||
goal="Операційні дії по farmOS (читання/через integration write).",
|
||||
backstory="Ти працюєш з farmOS лише через інструменти. Прямі записи заборонені.",
|
||||
tools=[
|
||||
tools.tool_farmos_read,
|
||||
tools.tool_integration_write
|
||||
],
|
||||
allow_delegation=False,
|
||||
verbose=True
|
||||
)
|
||||
16
crews/agromatrix_crew/agents/platform_agent.py
Normal file
16
crews/agromatrix_crew/agents/platform_agent.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from crewai import Agent
|
||||
from crews.agromatrix_crew import tools
|
||||
|
||||
|
||||
def build_platform():
|
||||
return Agent(
|
||||
role="Platform Agent",
|
||||
goal="Платформна перевірка стану сервісів/інтеграцій.",
|
||||
backstory="Доступ лише через інструменти подій/читання.",
|
||||
tools=[
|
||||
tools.tool_event_bus,
|
||||
tools.tool_farmos_read
|
||||
],
|
||||
allow_delegation=False,
|
||||
verbose=True
|
||||
)
|
||||
15
crews/agromatrix_crew/agents/spreadsheet_agent.py
Normal file
15
crews/agromatrix_crew/agents/spreadsheet_agent.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from crewai import Agent
|
||||
from crews.agromatrix_crew import tools
|
||||
|
||||
|
||||
def build_spreadsheet():
|
||||
return Agent(
|
||||
role="Spreadsheet Agent",
|
||||
goal="Читати/редагувати/створювати XLSX файли та формувати артефакти.",
|
||||
backstory="Використовує лише spreadsheet інструмент.",
|
||||
tools=[
|
||||
tools.tool_spreadsheet
|
||||
],
|
||||
allow_delegation=False,
|
||||
verbose=True
|
||||
)
|
||||
11
crews/agromatrix_crew/agents/stepan_orchestrator.py
Normal file
11
crews/agromatrix_crew/agents/stepan_orchestrator.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from crewai import Agent
|
||||
|
||||
|
||||
def build_stepan():
|
||||
return Agent(
|
||||
role="Stepan (AgroMatrix Orchestrator)",
|
||||
goal="Керувати запитами користувача через делегування під-агентам і повертати єдину відповідь.",
|
||||
backstory="Ти єдиний канал спілкування з користувачем. Під-агенти працюють лише через інструменти.",
|
||||
allow_delegation=True,
|
||||
verbose=True
|
||||
)
|
||||
15
crews/agromatrix_crew/agents/sustainability_agent.py
Normal file
15
crews/agromatrix_crew/agents/sustainability_agent.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from crewai import Agent
|
||||
from crews.agromatrix_crew import tools
|
||||
|
||||
|
||||
def build_sustainability():
|
||||
return Agent(
|
||||
role="Sustainability Agent",
|
||||
goal="Агрегати та аналітика (LiteFarm read-only).",
|
||||
backstory="Працює лише з read-only LiteFarm інструментом.",
|
||||
tools=[
|
||||
tools.tool_litefarm_read
|
||||
],
|
||||
allow_delegation=False,
|
||||
verbose=True
|
||||
)
|
||||
41
crews/agromatrix_crew/audit.py
Normal file
41
crews/agromatrix_crew/audit.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from nats.aio.client import Client as NATS
|
||||
import asyncio
|
||||
|
||||
NATS_URL = os.getenv('NATS_URL', 'nats://localhost:4222')
|
||||
AUDIT_FILE = os.getenv('AGX_AUDIT_FILE', 'artifacts/audit.log.jsonl')
|
||||
|
||||
|
||||
def _hash(text: str):
|
||||
return hashlib.sha256(text.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
async def _publish_nats(subject: str, payload: dict):
|
||||
nc = NATS()
|
||||
await nc.connect(servers=[NATS_URL])
|
||||
await nc.publish(subject, json.dumps(payload).encode())
|
||||
await nc.flush(1)
|
||||
await nc.drain()
|
||||
|
||||
|
||||
def audit_event(event: dict):
|
||||
Path(AUDIT_FILE).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(AUDIT_FILE, 'a', encoding='utf-8') as f:
|
||||
f.write(json.dumps(event, ensure_ascii=False) + '\n')
|
||||
try:
|
||||
asyncio.run(_publish_nats('agx.audit.delegation', event))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def new_trace(user_request: str):
|
||||
return {
|
||||
'trace_id': str(uuid.uuid4()),
|
||||
'user_request_hash': _hash(user_request),
|
||||
'ts': int(time.time() * 1000)
|
||||
}
|
||||
85
crews/agromatrix_crew/operation_schema.json
Normal file
85
crews/agromatrix_crew/operation_schema.json
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"OperationPlan": {
|
||||
"plan_id": "string",
|
||||
"created_ts": "ISO8601",
|
||||
"updated_ts": "ISO8601",
|
||||
"trace_id": "string",
|
||||
"source": "telegram|excel|api",
|
||||
"status": "planned|scheduled|in_progress|done|verified|closed|cancelled",
|
||||
"scope": {
|
||||
"field_ids": [
|
||||
"field_001"
|
||||
],
|
||||
"crop_ids": [
|
||||
"crop_wheat_winter"
|
||||
],
|
||||
"date_window": {
|
||||
"start": "YYYY-MM-DD",
|
||||
"end": "YYYY-MM-DD"
|
||||
}
|
||||
},
|
||||
"tasks": [
|
||||
{
|
||||
"task_id": "task_xxx",
|
||||
"operation_id": "op_sowing",
|
||||
"planned_date": "YYYY-MM-DD",
|
||||
"priority": "low|normal|high|critical",
|
||||
"assignee": "string",
|
||||
"norms": {
|
||||
"labor_hours": 0.0,
|
||||
"fuel_l": 0.0,
|
||||
"materials": [
|
||||
{
|
||||
"material_id": "mat_urea",
|
||||
"rate": {
|
||||
"value": 150,
|
||||
"unit": "kg/ha"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"constraints": {
|
||||
"weather": [
|
||||
"no_rain"
|
||||
],
|
||||
"window": {
|
||||
"start": "YYYY-MM-DD",
|
||||
"end": "YYYY-MM-DD"
|
||||
}
|
||||
},
|
||||
"notes": ""
|
||||
}
|
||||
],
|
||||
"fact_events": [
|
||||
{
|
||||
"fact_id": "fact_xxx",
|
||||
"task_id": "task_xxx",
|
||||
"ts": "ISO8601",
|
||||
"field_id": "field_001",
|
||||
"operation_id": "op_sowing",
|
||||
"done_date": "YYYY-MM-DD",
|
||||
"fact": {
|
||||
"labor_hours": 0.0,
|
||||
"fuel_l": 0.0,
|
||||
"materials": [
|
||||
{
|
||||
"material_id": "mat_urea",
|
||||
"amount": {
|
||||
"value": 180,
|
||||
"unit": "kg/ha"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"quality": {
|
||||
"source": "manual|sensor|import",
|
||||
"confidence": "trusted|low_confidence"
|
||||
},
|
||||
"farmos_write": {
|
||||
"status": "pending|ok|failed",
|
||||
"ref": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
364
crews/agromatrix_crew/operator_commands.py
Normal file
364
crews/agromatrix_crew/operator_commands.py
Normal file
@@ -0,0 +1,364 @@
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
|
||||
from agromatrix_tools import tool_dictionary_review as review
|
||||
|
||||
CATEGORIES = {"field","crop","operation","material","unit"}
|
||||
|
||||
|
||||
def is_operator(user_id: str | None, chat_id: str | None) -> bool:
|
||||
allowed_ids = [s.strip() for s in os.getenv('AGX_OPERATOR_IDS', '').split(',') if s.strip()]
|
||||
allowed_chat = os.getenv('AGX_OPERATOR_CHAT_ID', '').strip()
|
||||
if allowed_chat and chat_id and str(chat_id) != allowed_chat:
|
||||
return False
|
||||
if allowed_ids and user_id and str(user_id) in allowed_ids:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def parse_operator_command(text: str):
|
||||
parts = shlex.split(text)
|
||||
if not parts:
|
||||
return None
|
||||
cmd = parts[0].lstrip('/')
|
||||
args = parts[1:]
|
||||
return {"cmd": cmd, "args": args}
|
||||
|
||||
|
||||
def _wrap(summary: str, details: dict | None = None):
|
||||
return {
|
||||
"status": "ok",
|
||||
"summary": summary,
|
||||
"artifacts": [],
|
||||
"tool_calls": [],
|
||||
"next_actions": [],
|
||||
"details": details or {}
|
||||
}
|
||||
|
||||
|
||||
def _extract_ref(text: str) -> str | None:
|
||||
m = re.search(r"pending\.jsonl:\d+", text)
|
||||
return m.group(0) if m else None
|
||||
|
||||
|
||||
def _extract_index(text: str) -> int | None:
|
||||
m = re.search(r"(\d{1,3})", text)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return int(m.group(1))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_limit(text: str, default: int = 10) -> int:
|
||||
m = re.search(r"(?:до|limit)\s*(\d{1,3})", text)
|
||||
if m:
|
||||
try:
|
||||
return int(m.group(1))
|
||||
except Exception:
|
||||
return default
|
||||
m = re.search(r"(\d{1,3})", text)
|
||||
if m:
|
||||
try:
|
||||
return int(m.group(1))
|
||||
except Exception:
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def _extract_category(text: str) -> str | None:
|
||||
t = text.lower()
|
||||
if 'один' in t or 'unit' in t:
|
||||
return 'unit'
|
||||
if 'операц' in t or 'operation' in t:
|
||||
return 'operation'
|
||||
if 'культур' in t or 'crop' in t:
|
||||
return 'crop'
|
||||
if 'матеріал' in t or 'material' in t:
|
||||
return 'material'
|
||||
if 'поле' in t or 'field' in t:
|
||||
return 'field'
|
||||
return None
|
||||
|
||||
|
||||
def _extract_canonical_id(text: str) -> str | None:
|
||||
m = re.search(r"(?:як|as)\s+([\w\-:.]+)", text, flags=re.IGNORECASE)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_reason(text: str) -> str:
|
||||
if ':' in text:
|
||||
return text.split(':', 1)[1].strip()
|
||||
m = re.search(r"(?:бо|через)\s+(.+)$", text, flags=re.IGNORECASE)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
return ''
|
||||
|
||||
|
||||
def _resolve_ref_by_index(last_list: list | None, idx: int | None) -> str | None:
|
||||
if not last_list or not idx:
|
||||
return None
|
||||
if idx < 1 or idx > len(last_list):
|
||||
return None
|
||||
item = last_list[idx - 1]
|
||||
return item.get('pending_ref') or item.get('ref')
|
||||
|
||||
|
||||
def handle_whoami(user_id: str | None, chat_id: str | None):
|
||||
summary = "user_id: {}\nchat_id: {}".format(user_id or '', chat_id or '')
|
||||
return _wrap(summary)
|
||||
|
||||
|
||||
def handle_pending(limit=10, category=None):
|
||||
items = review.list_pending(limit=limit, category=category)
|
||||
lines = []
|
||||
pending_items = []
|
||||
for item in items:
|
||||
sug = item.get('suggestions', [])[:5]
|
||||
sug_s = ', '.join([f"{s['id']}({s['score']:.2f})" for s in sug])
|
||||
lines.append(f"{item['pending_ref']} | {item.get('category')} | {item.get('raw_term')} | {sug_s}")
|
||||
pending_items.append({
|
||||
'pending_ref': item.get('pending_ref'),
|
||||
'pending_id': item.get('pending_id'),
|
||||
'raw_term': item.get('raw_term'),
|
||||
'category': item.get('category'),
|
||||
'suggestions': [{"id": s.get('id'), "score": s.get('score')} for s in sug]
|
||||
})
|
||||
summary = "\n".join(lines) if lines else "Немає pending записів."
|
||||
return _wrap(summary, {"count": len(items), "pending_items": pending_items})
|
||||
|
||||
|
||||
def handle_pending_show(ref: str):
|
||||
detail = review.get_pending_detail(ref)
|
||||
if not detail:
|
||||
return _wrap('Не знайдено pending запис')
|
||||
|
||||
lines = [
|
||||
f"ref: {detail.get('ref')}",
|
||||
f"category: {detail.get('category')}",
|
||||
f"raw_term: {detail.get('raw_term')}",
|
||||
f"ts: {detail.get('ts')}",
|
||||
f"status: {detail.get('status')}"
|
||||
]
|
||||
if detail.get('decision'):
|
||||
lines.append(f"decision: {detail.get('decision')}")
|
||||
if detail.get('reason'):
|
||||
lines.append(f"reason: {detail.get('reason')}")
|
||||
|
||||
lines.append('suggestions:')
|
||||
suggestions = detail.get('suggestions') or []
|
||||
if suggestions:
|
||||
for s in suggestions:
|
||||
score = s.get('score')
|
||||
score_s = f"{score:.2f}" if isinstance(score, (int, float)) else "n/a"
|
||||
lines.append(f"- {s.get('id')} ({score_s})")
|
||||
else:
|
||||
lines.append('- (none)')
|
||||
|
||||
return _wrap("\n".join(lines), detail)
|
||||
|
||||
def handle_approve(ref, map_to=None, create_new=None, category=None, name=None, id_=None, apply=False):
|
||||
if map_to:
|
||||
action = {"type": "map_to_existing", "canonical_id": map_to}
|
||||
elif create_new:
|
||||
action = {"type": "create_new_entry", "canonical_id": id_ or '', "canonical_name": name or ''}
|
||||
else:
|
||||
action = {"type": "add_synonym", "canonical_id": map_to}
|
||||
res = review.approve_pending(ref, action)
|
||||
if apply:
|
||||
review.apply_resolutions()
|
||||
return _wrap(f"approved {ref}", {"record": res, "applied": apply})
|
||||
|
||||
|
||||
def handle_reject(ref, reason):
|
||||
res = review.reject_pending(ref, reason)
|
||||
return _wrap(f"rejected {ref}", {"record": res})
|
||||
|
||||
|
||||
def handle_apply():
|
||||
count = review.apply_resolutions()
|
||||
return _wrap(f"applied {count}")
|
||||
|
||||
|
||||
def handle_stats():
|
||||
stats = review.stats()
|
||||
return _wrap(f"open={stats['open']} approved={stats['approved']} rejected={stats['rejected']}", stats)
|
||||
|
||||
|
||||
def route_operator_text(text: str, user_id: str | None, chat_id: str | None, last_pending_list: list | None = None):
|
||||
if not is_operator(user_id, chat_id):
|
||||
return {
|
||||
"status": "error",
|
||||
"summary": "Недостатньо прав",
|
||||
"artifacts": [],
|
||||
"tool_calls": [],
|
||||
"next_actions": []
|
||||
}
|
||||
|
||||
t = text.strip().lower()
|
||||
|
||||
# list pending
|
||||
if any(k in t for k in ['покажи', 'показати', 'невпізнан', 'непізнан', 'pending', 'невідом']):
|
||||
category = _extract_category(t)
|
||||
limit = _extract_limit(t, default=10)
|
||||
if limit < 1:
|
||||
limit = 1
|
||||
if limit > 100:
|
||||
limit = 100
|
||||
if category and category not in CATEGORIES:
|
||||
return _wrap('unknown category')
|
||||
return handle_pending(limit=limit, category=category)
|
||||
|
||||
# stats
|
||||
if any(k in t for k in ['статист', 'скільки pending', 'pending stats']):
|
||||
return handle_stats()
|
||||
|
||||
# apply
|
||||
if any(k in t for k in ['застосуй', 'apply', 'застосувати', 'застосуй зміни']):
|
||||
return handle_apply()
|
||||
|
||||
# details
|
||||
if any(k in t for k in ['деталі', 'detail', 'подробиц', 'покажи деталі']):
|
||||
ref = _extract_ref(t)
|
||||
if not ref:
|
||||
idx = _extract_index(t)
|
||||
ref = _resolve_ref_by_index(last_pending_list, idx)
|
||||
if not ref:
|
||||
return _wrap('Немає ref або контексту для деталей')
|
||||
return handle_pending_show(ref)
|
||||
|
||||
# approve
|
||||
if any(k in t for k in ['підтверд', 'approve', 'схвали']):
|
||||
ref = _extract_ref(t)
|
||||
if not ref:
|
||||
idx = _extract_index(t)
|
||||
ref = _resolve_ref_by_index(last_pending_list, idx)
|
||||
if not ref:
|
||||
return _wrap('Немає ref або контексту для підтвердження')
|
||||
canonical_id = _extract_canonical_id(t)
|
||||
if not canonical_id:
|
||||
return _wrap('Вкажіть canonical_id після "як"')
|
||||
return handle_approve(ref, map_to=canonical_id)
|
||||
|
||||
# reject
|
||||
if any(k in t for k in ['відхил', 'reject', 'забракуй']):
|
||||
ref = _extract_ref(t)
|
||||
if not ref:
|
||||
idx = _extract_index(t)
|
||||
ref = _resolve_ref_by_index(last_pending_list, idx)
|
||||
if not ref:
|
||||
return _wrap('Немає ref або контексту для відхилення')
|
||||
reason = _extract_reason(text)
|
||||
if not reason:
|
||||
return _wrap('Вкажіть причину відхилення')
|
||||
return handle_reject(ref, reason)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def route_operator_command(text: str, user_id: str | None, chat_id: str | None):
|
||||
parsed = parse_operator_command(text)
|
||||
if not parsed:
|
||||
return None
|
||||
if not is_operator(user_id, chat_id):
|
||||
return {
|
||||
"status": "error",
|
||||
"summary": "Недостатньо прав",
|
||||
"artifacts": [],
|
||||
"tool_calls": [],
|
||||
"next_actions": []
|
||||
}
|
||||
|
||||
cmd = parsed['cmd']
|
||||
args = parsed['args']
|
||||
|
||||
if cmd == 'whoami':
|
||||
return handle_whoami(user_id, chat_id)
|
||||
|
||||
if cmd == 'pending_show':
|
||||
if not args:
|
||||
return _wrap('Потрібен ref')
|
||||
return handle_pending_show(args[0])
|
||||
|
||||
if cmd == 'pending':
|
||||
limit = 10
|
||||
category = None
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == '--limit' and i + 1 < len(args):
|
||||
try:
|
||||
limit = int(args[i+1])
|
||||
except Exception:
|
||||
limit = 10
|
||||
i += 2
|
||||
continue
|
||||
if args[i] == '--category' and i + 1 < len(args):
|
||||
category = args[i+1]
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
# clamp
|
||||
if limit < 1:
|
||||
limit = 1
|
||||
if limit > 100:
|
||||
limit = 100
|
||||
if category and category not in CATEGORIES:
|
||||
return _wrap('unknown category')
|
||||
return handle_pending(limit=limit, category=category)
|
||||
|
||||
if cmd == 'approve':
|
||||
if not args:
|
||||
return _wrap('missing ref')
|
||||
ref = args[0]
|
||||
map_to = None
|
||||
create_new = False
|
||||
category = None
|
||||
name = None
|
||||
id_ = None
|
||||
apply = False
|
||||
i = 1
|
||||
while i < len(args):
|
||||
if args[i] == 'map_to':
|
||||
map_to = args[i+1]
|
||||
i += 2
|
||||
elif args[i] == '--apply':
|
||||
apply = True
|
||||
i += 1
|
||||
elif args[i] == 'create_new':
|
||||
create_new = True
|
||||
i += 1
|
||||
elif args[i] == 'category':
|
||||
category = args[i+1]
|
||||
i += 2
|
||||
elif args[i] == 'name':
|
||||
name = args[i+1]
|
||||
i += 2
|
||||
elif args[i] == 'id':
|
||||
id_ = args[i+1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
if apply:
|
||||
allow_apply = os.getenv('AGX_ALLOW_APPLY', '0') == '1' or os.getenv('AGX_OPS_MODE', '0') == '1'
|
||||
if not allow_apply:
|
||||
return _wrap('apply_not_allowed')
|
||||
return handle_approve(ref, map_to=map_to, create_new=create_new, category=category, name=name, id_=id_, apply=apply)
|
||||
|
||||
if cmd == 'reject':
|
||||
if len(args) < 2:
|
||||
return _wrap('missing ref or reason')
|
||||
ref = args[0]
|
||||
reason = ' '.join(args[1:])
|
||||
return handle_reject(ref, reason)
|
||||
|
||||
if cmd == 'apply_dict':
|
||||
return handle_apply()
|
||||
|
||||
if cmd == 'pending_stats':
|
||||
return handle_stats()
|
||||
|
||||
return _wrap('unknown command')
|
||||
233
crews/agromatrix_crew/run.py
Normal file
233
crews/agromatrix_crew/run.py
Normal file
@@ -0,0 +1,233 @@
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from crewai import Crew, Task
|
||||
from crews.agromatrix_crew.agents.stepan_orchestrator import build_stepan
|
||||
from crews.agromatrix_crew.agents.operations_agent import build_operations
|
||||
from crews.agromatrix_crew.agents.iot_agent import build_iot
|
||||
from crews.agromatrix_crew.agents.platform_agent import build_platform
|
||||
from crews.agromatrix_crew.agents.spreadsheet_agent import build_spreadsheet
|
||||
from crews.agromatrix_crew.agents.sustainability_agent import build_sustainability
|
||||
from crews.agromatrix_crew.audit import audit_event, new_trace
|
||||
from agromatrix_tools import tool_dictionary
|
||||
from agromatrix_tools import tool_operation_plan
|
||||
from crews.agromatrix_crew.operator_commands import route_operator_command, route_operator_text
|
||||
|
||||
|
||||
def farmos_ui_hint():
|
||||
port = os.getenv('FARMOS_UI_PORT', '18080')
|
||||
try:
|
||||
out = subprocess.check_output(['docker','ps','--format','{{.Names}}'], text=True)
|
||||
if 'farmos_ui_proxy' in out:
|
||||
return "\n[UI] farmOS доступний локально: http://127.0.0.1:{} (basic auth)".format(port)
|
||||
except Exception:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
def detect_intent(text: str) -> str:
|
||||
t = text.lower()
|
||||
if 'сплануй' in t and 'тиж' in t:
|
||||
return 'plan_week'
|
||||
if 'сплануй' in t:
|
||||
return 'plan_day'
|
||||
if 'критично' in t or 'на завтра' in t:
|
||||
return 'show_critical_tomorrow'
|
||||
if 'план/факт' in t or 'план факт' in t:
|
||||
return 'plan_vs_fact'
|
||||
if 'закрий план' in t:
|
||||
return 'close_plan'
|
||||
return 'general'
|
||||
|
||||
|
||||
def validate_payload(obj: dict):
|
||||
required = ['status', 'summary', 'artifacts', 'tool_calls', 'next_actions']
|
||||
for k in required:
|
||||
if k not in obj:
|
||||
return False, f'missing:{k}'
|
||||
if obj['status'] not in ['ok', 'error']:
|
||||
return False, 'bad_status'
|
||||
if not isinstance(obj['summary'], str):
|
||||
return False, 'summary_not_string'
|
||||
if not isinstance(obj['artifacts'], list):
|
||||
return False, 'artifacts_not_list'
|
||||
if not isinstance(obj['tool_calls'], list):
|
||||
return False, 'tool_calls_not_list'
|
||||
if not isinstance(obj['next_actions'], list):
|
||||
return False, 'next_actions_not_list'
|
||||
return True, 'ok'
|
||||
|
||||
|
||||
def run_task_with_retry(agent, description: str, trace_id: str, max_retries: int = 2):
|
||||
instruction = "Return ONLY valid JSON matching schema in crews/agromatrix_crew/schema.json. No extra text."
|
||||
last_error = ''
|
||||
for attempt in range(max_retries + 1):
|
||||
desc = description if attempt == 0 else (description + "\n\n" + instruction)
|
||||
|
||||
task = Task(
|
||||
description=desc,
|
||||
expected_output="JSON strictly matching schema.json",
|
||||
agent=agent
|
||||
)
|
||||
crew = Crew(agents=[agent], tasks=[task], verbose=True)
|
||||
result = crew.kickoff()
|
||||
raw = str(result)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
ok, reason = validate_payload(data)
|
||||
if ok:
|
||||
return data
|
||||
last_error = reason
|
||||
except Exception as e:
|
||||
last_error = f'json_error:{e}'
|
||||
audit_event({
|
||||
'trace_id': trace_id,
|
||||
'agent': agent.role,
|
||||
'action': 'json_validation_failed',
|
||||
'error': last_error
|
||||
})
|
||||
return {
|
||||
'status': 'error',
|
||||
'summary': f'JSON validation failed: {last_error}',
|
||||
'artifacts': [],
|
||||
'tool_calls': [],
|
||||
'next_actions': []
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
trace = new_trace(text)
|
||||
if trace_id:
|
||||
trace['trace_id'] = trace_id
|
||||
|
||||
os.environ['AGX_TRACE_ID'] = trace['trace_id']
|
||||
os.environ['AGX_USER_ID'] = str(user_id)
|
||||
os.environ['AGX_CHAT_ID'] = str(chat_id)
|
||||
os.environ['AGX_OPS_MODE'] = '1' if ops_mode else '0'
|
||||
|
||||
# operator commands
|
||||
if text.strip().startswith('/'):
|
||||
op_res = route_operator_command(text, str(user_id), str(chat_id))
|
||||
if op_res:
|
||||
return json.dumps(op_res, ensure_ascii=False)
|
||||
elif ops_mode:
|
||||
op_res = route_operator_text(text, str(user_id), str(chat_id), last_pending_list=last_pending_list)
|
||||
if op_res:
|
||||
return json.dumps(op_res, ensure_ascii=False)
|
||||
|
||||
stepan = build_stepan()
|
||||
ops = build_operations()
|
||||
iot = build_iot()
|
||||
platform = build_platform()
|
||||
spreadsheet = build_spreadsheet()
|
||||
sustainability = build_sustainability()
|
||||
|
||||
audit_event({**trace, 'agent': 'stepan', 'action': 'intake'})
|
||||
|
||||
# Preflight normalization
|
||||
norm = tool_dictionary.normalize_from_text(text, trace_id=trace['trace_id'], source='telegram')
|
||||
pending = [item for cat in norm.values() for item in cat if item.get('status') == 'pending']
|
||||
if pending:
|
||||
lines = ["=== PENDING TERMS (Stepan) ==="]
|
||||
for item in pending:
|
||||
lines.append(f"- {item.get('term')}: {item.get('suggestions', [])[:3]}")
|
||||
lines.append("\nБудь ласка, уточніть невідомі терміни. Після підтвердження я продовжу.")
|
||||
return "\n".join(lines)
|
||||
|
||||
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
|
||||
|
||||
if intent in ['plan_week', 'plan_day']:
|
||||
plan_id = tool_operation_plan.create_plan({
|
||||
'scope': {
|
||||
'field_ids': [i.get('normalized_id') for i in norm.get('fields', []) if i.get('status')=='ok'],
|
||||
'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')
|
||||
return json.dumps({
|
||||
'status': 'ok',
|
||||
'summary': f'План створено: {plan_id}',
|
||||
'artifacts': [],
|
||||
'tool_calls': [],
|
||||
'next_actions': ['уточнити дати та операції'],
|
||||
'pending_dictionary_items': pending_count if ops_mode else None
|
||||
}, ensure_ascii=False)
|
||||
|
||||
if intent == 'show_critical_tomorrow':
|
||||
_ = tool_operation_plan.plan_dashboard({}, {})
|
||||
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':
|
||||
_ = tool_operation_plan.plan_dashboard({}, {})
|
||||
return json.dumps({
|
||||
'status': 'ok',
|
||||
'summary': 'План/факт зведення',
|
||||
'artifacts': [],
|
||||
'tool_calls': [],
|
||||
'next_actions': [],
|
||||
'pending_dictionary_items': pending_count if ops_mode else None
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# general crew flow
|
||||
ops_out = run_task_with_retry(ops, "Оціни чи потрібні операційні записи або читання farmOS", trace['trace_id'])
|
||||
iot_out = run_task_with_retry(iot, "Оціни чи є потреба в даних ThingsBoard або NATS", trace['trace_id'])
|
||||
platform_out = run_task_with_retry(platform, "Перевір базовий статус сервісів/інтеграцій", trace['trace_id'])
|
||||
sheet_out = run_task_with_retry(spreadsheet, "Якщо запит стосується таблиць — підготуй артефакти", trace['trace_id'])
|
||||
sustainability_out = run_task_with_retry(sustainability, "Якщо потрібні агрегації — дай read-only підсумки", trace['trace_id'])
|
||||
|
||||
audit_event({**trace, 'agent': 'stepan', 'action': 'delegate', 'targets': ['ops','iot','platform','spreadsheet','sustainability']})
|
||||
|
||||
summary = {
|
||||
'ops': ops_out,
|
||||
'iot': iot_out,
|
||||
'platform': platform_out,
|
||||
'spreadsheet': sheet_out,
|
||||
'sustainability': sustainability_out
|
||||
}
|
||||
|
||||
final_task = Task(
|
||||
description=f"Сформуй фінальну коротку відповідь користувачу. Вхідні дані (JSON): {json.dumps(summary, ensure_ascii=False)}",
|
||||
expected_output="Коротка консолідована відповідь для користувача українською.",
|
||||
agent=stepan
|
||||
)
|
||||
crew = Crew(agents=[stepan], tasks=[final_task], verbose=True)
|
||||
result = crew.kickoff()
|
||||
|
||||
return str(result) + farmos_ui_hint()
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print('Usage: python run.py [--trace <id>] "<user request>"')
|
||||
sys.exit(1)
|
||||
|
||||
args = sys.argv[1:]
|
||||
trace_override = None
|
||||
if args and args[0] == '--trace':
|
||||
trace_override = args[1]
|
||||
args = args[2:]
|
||||
|
||||
user_request = args[0]
|
||||
output = handle_message(user_request, user_id=os.getenv('AGX_USER_ID',''), chat_id=os.getenv('AGX_CHAT_ID',''), trace_id=trace_override or '', ops_mode=os.getenv('AGX_OPS_MODE','0')=='1')
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
7
crews/agromatrix_crew/schema.json
Normal file
7
crews/agromatrix_crew/schema.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"status": "ok|error",
|
||||
"summary": "string",
|
||||
"artifacts": [{"type": "string", "path": "string", "description": "string"}],
|
||||
"tool_calls": [{"tool": "string", "input": {}, "output_ref": "string"}],
|
||||
"next_actions": ["string"]
|
||||
}
|
||||
9
crews/agromatrix_crew/tasks/execute_iot.py
Normal file
9
crews/agromatrix_crew/tasks/execute_iot.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from crewai import Task
|
||||
|
||||
|
||||
def build_task(agent, instructions: str):
|
||||
return Task(
|
||||
description=f"Виконай IoT/ThingsBoard дії: {instructions}",
|
||||
expected_output="JSON строго за схемою у crews/agromatrix_crew/schema.json. "Структурований результат IoT дій.",
|
||||
agent=agent
|
||||
)
|
||||
9
crews/agromatrix_crew/tasks/execute_ops.py
Normal file
9
crews/agromatrix_crew/tasks/execute_ops.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from crewai import Task
|
||||
|
||||
|
||||
def build_task(agent, instructions: str):
|
||||
return Task(
|
||||
description=f"Виконай операційні дії: {instructions}",
|
||||
expected_output="JSON строго за схемою у crews/agromatrix_crew/schema.json. "Структурований результат виконання операцій.",
|
||||
agent=agent
|
||||
)
|
||||
9
crews/agromatrix_crew/tasks/execute_spreadsheets.py
Normal file
9
crews/agromatrix_crew/tasks/execute_spreadsheets.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from crewai import Task
|
||||
|
||||
|
||||
def build_task(agent, instructions: str):
|
||||
return Task(
|
||||
description=f"Виконай дії з таблицями: {instructions}",
|
||||
expected_output="JSON строго за схемою у crews/agromatrix_crew/schema.json. "Посилання на артефакти та короткий опис змін.",
|
||||
agent=agent
|
||||
)
|
||||
9
crews/agromatrix_crew/tasks/intake_and_plan.py
Normal file
9
crews/agromatrix_crew/tasks/intake_and_plan.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from crewai import Task
|
||||
|
||||
|
||||
def build_task(stepan, user_request: str):
|
||||
return Task(
|
||||
description=f"Оціни запит користувача і склади план делегування: {user_request}",
|
||||
expected_output="JSON строго за схемою у crews/agromatrix_crew/schema.json. "Структурований план: які агенти, які задачі, які інструменти.",
|
||||
agent=stepan
|
||||
)
|
||||
9
crews/agromatrix_crew/tasks/reporting.py
Normal file
9
crews/agromatrix_crew/tasks/reporting.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from crewai import Task
|
||||
|
||||
|
||||
def build_task(stepan, summary: str):
|
||||
return Task(
|
||||
description=f"Сформуй фінальну відповідь користувачу на основі: {summary}",
|
||||
expected_output="JSON строго за схемою у crews/agromatrix_crew/schema.json. "Коротка консолідована відповідь користувачу.",
|
||||
agent=stepan
|
||||
)
|
||||
8
crews/agromatrix_crew/tools/__init__.py
Normal file
8
crews/agromatrix_crew/tools/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
from agromatrix_tools import tool_farmos_read
|
||||
from agromatrix_tools import tool_integration_write
|
||||
from agromatrix_tools import tool_thingsboard_read
|
||||
from agromatrix_tools import tool_event_bus
|
||||
from agromatrix_tools import tool_spreadsheet
|
||||
from agromatrix_tools import tool_litefarm_read
|
||||
from agromatrix_tools import tool_tania_control
|
||||
Reference in New Issue
Block a user