379 lines
12 KiB
Python
379 lines
12 KiB
Python
import os
|
||
import re
|
||
import shlex
|
||
|
||
from agromatrix_tools import tool_dictionary_review as review
|
||
|
||
CATEGORIES = {"field","crop","operation","material","unit"}
|
||
|
||
# Only these slash-commands are treated as operator commands.
|
||
# Everything else (e.g. /start, /agromatrix) must fall through to the normal agent flow.
|
||
OPERATOR_COMMANDS = {
|
||
"whoami",
|
||
"pending",
|
||
"pending_show",
|
||
"approve",
|
||
"reject",
|
||
"apply_dict",
|
||
"pending_stats",
|
||
}
|
||
|
||
|
||
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:]
|
||
if cmd not in OPERATOR_COMMANDS:
|
||
return None
|
||
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')
|